mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 23:17:59 -05:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f0ef58056 | ||
|
|
33ca1483aa | ||
|
|
620ce97056 | ||
|
|
25ac91779b | ||
|
|
d51a756c1b | ||
|
|
3d1feab507 | ||
|
|
98908dbfb9 | ||
|
|
00d9b45a22 | ||
|
|
b5b2855b40 | ||
|
|
a81f3847df | ||
|
|
6f3dee867c | ||
|
|
bfa7c919d8 | ||
|
|
e37b01b92c | ||
|
|
7e3e38a6f2 | ||
|
|
1c85fe9a51 | ||
|
|
5f446ad756 | ||
|
|
d99d5fe39c | ||
|
|
949f9287cf | ||
|
|
fca92a7499 | ||
|
|
c25ea5c677 | ||
|
|
dccd9e9ce5 | ||
|
|
b5d9964c48 | ||
|
|
4bd0f31f36 | ||
|
|
f8070f9029 | ||
|
|
bc8947caa6 | ||
|
|
f1111ec16f | ||
|
|
d0767507b2 | ||
|
|
8bd75debc1 | ||
|
|
ad2a375358 | ||
|
|
de91dc97a9 | ||
|
|
31ed712378 |
@@ -198,15 +198,17 @@ export default async function Page(props: { params: Promise<{ slug?: string[]; l
|
||||
component: <CustomFooter />,
|
||||
}}
|
||||
>
|
||||
<div className='relative'>
|
||||
<div className='relative mt-6 sm:mt-0'>
|
||||
<div className='absolute top-1 right-0 flex items-center gap-2'>
|
||||
<CopyPageButton
|
||||
content={`# ${page.data.title}
|
||||
<div className='hidden sm:flex'>
|
||||
<CopyPageButton
|
||||
content={`# ${page.data.title}
|
||||
|
||||
${page.data.description || ''}
|
||||
|
||||
${page.data.content || ''}`}
|
||||
/>
|
||||
/>
|
||||
</div>
|
||||
<PageNavigationArrows previous={neighbours?.previous} next={neighbours?.next} />
|
||||
</div>
|
||||
<DocsTitle>{page.data.title}</DocsTitle>
|
||||
|
||||
@@ -69,7 +69,7 @@ export function SidebarFolder({
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className='rounded p-1 transition-colors hover:bg-gray-100/60 dark:hover:bg-gray-800/40'
|
||||
className='cursor-pointer rounded p-1 transition-colors hover:bg-gray-100/60 dark:hover:bg-gray-800/40'
|
||||
aria-label={open ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
<ChevronRight
|
||||
@@ -84,7 +84,7 @@ export function SidebarFolder({
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between rounded-md px-2.5 py-1.5 text-left font-medium text-[13px] leading-tight transition-colors',
|
||||
'flex w-full cursor-pointer items-center justify-between rounded-md px-2.5 py-1.5 text-left font-medium text-[13px] leading-tight transition-colors',
|
||||
'hover:bg-gray-100/60 dark:hover:bg-gray-800/40',
|
||||
'text-gray-800 dark:text-gray-200'
|
||||
)}
|
||||
|
||||
@@ -30,7 +30,7 @@ export function CodeBlock(props: React.ComponentProps<typeof FumadocsCodeBlock>)
|
||||
if (pre) handleCopy(pre.textContent || '')
|
||||
}}
|
||||
className={cn(
|
||||
'rounded-md p-2 transition-all',
|
||||
'cursor-pointer rounded-md p-2 transition-all',
|
||||
'border border-border bg-background/80 hover:bg-muted',
|
||||
'backdrop-blur-sm'
|
||||
)}
|
||||
|
||||
@@ -23,7 +23,7 @@ export function CopyPageButton({ content }: CopyPageButtonProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className='flex items-center gap-1.5 rounded-lg border border-border/40 bg-background px-2.5 py-2 text-muted-foreground/60 text-sm leading-none transition-all hover:border-border hover:bg-accent/50 hover:text-muted-foreground'
|
||||
className='flex cursor-pointer items-center gap-1.5 rounded-lg border border-border/40 bg-background px-2.5 py-2 text-muted-foreground/60 text-sm leading-none transition-all hover:border-border hover:bg-accent/50 hover:text-muted-foreground'
|
||||
aria-label={copied ? 'Copied to clipboard' : 'Copy page content'}
|
||||
>
|
||||
{copied ? (
|
||||
|
||||
@@ -82,7 +82,7 @@ export function LanguageDropdown() {
|
||||
aria-haspopup='listbox'
|
||||
aria-expanded={isOpen}
|
||||
aria-controls='language-menu'
|
||||
className='flex items-center gap-1.5 rounded-xl px-3 py-2 font-normal text-[0.9375rem] text-foreground/60 leading-[1.4] transition-colors hover:bg-foreground/8 hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring'
|
||||
className='flex cursor-pointer items-center gap-1.5 rounded-xl px-3 py-2 font-normal text-[0.9375rem] text-foreground/60 leading-[1.4] transition-colors hover:bg-foreground/8 hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring'
|
||||
style={{
|
||||
fontFamily:
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
||||
@@ -110,7 +110,7 @@ export function LanguageDropdown() {
|
||||
}}
|
||||
role='option'
|
||||
aria-selected={currentLang === code}
|
||||
className={`flex w-full items-center gap-3 px-3 py-3 text-base transition-colors first:rounded-t-xl last:rounded-b-xl hover:bg-muted/80 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring md:gap-2 md:px-2.5 md:py-2 md:text-sm ${
|
||||
className={`flex w-full cursor-pointer items-center gap-3 px-3 py-3 text-base transition-colors first:rounded-t-xl last:rounded-b-xl hover:bg-muted/80 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring md:gap-2 md:px-2.5 md:py-2 md:text-sm ${
|
||||
currentLang === code ? 'bg-muted/60 font-medium text-primary' : 'text-foreground'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -15,7 +15,7 @@ export function SearchTrigger() {
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
className='flex h-10 w-[460px] items-center gap-2 rounded-xl border border-border/50 px-3 py-2 text-sm backdrop-blur-xl transition-colors hover:border-border'
|
||||
className='flex h-10 w-[460px] cursor-pointer items-center gap-2 rounded-xl border border-border/50 px-3 py-2 text-sm backdrop-blur-xl transition-colors hover:border-border'
|
||||
style={{
|
||||
backgroundColor: 'hsla(0, 0%, 5%, 0.85)',
|
||||
backdropFilter: 'blur(33px) saturate(180%)',
|
||||
|
||||
@@ -14,7 +14,7 @@ export function ThemeToggle() {
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<button className='flex items-center justify-center rounded-md p-1 text-muted-foreground'>
|
||||
<button className='flex cursor-pointer items-center justify-center rounded-md p-1 text-muted-foreground'>
|
||||
<Moon className='h-4 w-4' />
|
||||
</button>
|
||||
)
|
||||
@@ -23,7 +23,7 @@ export function ThemeToggle() {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||||
className='flex items-center justify-center rounded-md p-1 text-muted-foreground transition-colors hover:text-foreground'
|
||||
className='flex cursor-pointer items-center justify-center rounded-md p-1 text-muted-foreground transition-colors hover:text-foreground'
|
||||
aria-label='Toggle theme'
|
||||
>
|
||||
{theme === 'dark' ? <Moon className='h-4 w-4' /> : <Sun className='h-4 w-4' />}
|
||||
|
||||
@@ -20,7 +20,7 @@ interface NavProps {
|
||||
}
|
||||
|
||||
export default function Nav({ hideAuthButtons = false, variant = 'landing' }: NavProps = {}) {
|
||||
const [githubStars, setGithubStars] = useState('18k')
|
||||
const [githubStars, setGithubStars] = useState('18.5k')
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const [isLoginHovered, setIsLoginHovered] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { db } from '@sim/db'
|
||||
import { account } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { validateMicrosoftGraphId } from '@/lib/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/utils'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { getCredential, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -15,15 +12,10 @@ const logger = createLogger('MicrosoftFileAPI')
|
||||
export async function GET(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const credentialId = searchParams.get('credentialId')
|
||||
const fileId = searchParams.get('fileId')
|
||||
const workflowId = searchParams.get('workflowId') || undefined
|
||||
|
||||
if (!credentialId || !fileId) {
|
||||
return NextResponse.json({ error: 'Credential ID and File ID are required' }, { status: 400 })
|
||||
@@ -35,19 +27,27 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: fileIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
|
||||
const authz = await authorizeCredentialUse(request, {
|
||||
credentialId,
|
||||
workflowId,
|
||||
requireWorkflowIdForInternal: false,
|
||||
})
|
||||
|
||||
if (!credentials.length) {
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
const status = authz.error === 'Credential not found' ? 404 : 403
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status })
|
||||
}
|
||||
|
||||
const credential = await getCredential(requestId, credentialId, authz.credentialOwnerUserId)
|
||||
if (!credential) {
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const credential = credentials[0]
|
||||
|
||||
if (credential.userId !== session.user.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credentialId,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
)
|
||||
|
||||
if (!accessToken) {
|
||||
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { db } from '@sim/db'
|
||||
import { account } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { generateRequestId } from '@/lib/utils'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { getCredential, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -18,46 +15,39 @@ export async function GET(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
// Get the session
|
||||
const session = await getSession()
|
||||
|
||||
// Check if the user is authenticated
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthenticated request rejected`)
|
||||
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Get the credential ID from the query params
|
||||
const { searchParams } = new URL(request.url)
|
||||
const credentialId = searchParams.get('credentialId')
|
||||
const query = searchParams.get('query') || ''
|
||||
const workflowId = searchParams.get('workflowId') || undefined
|
||||
|
||||
if (!credentialId) {
|
||||
logger.warn(`[${requestId}] Missing credential ID`)
|
||||
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Get the credential from the database
|
||||
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
|
||||
const authz = await authorizeCredentialUse(request, {
|
||||
credentialId,
|
||||
workflowId,
|
||||
requireWorkflowIdForInternal: false,
|
||||
})
|
||||
|
||||
if (!credentials.length) {
|
||||
logger.warn(`[${requestId}] Credential not found`, { credentialId })
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
const status = authz.error === 'Credential not found' ? 404 : 403
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status })
|
||||
}
|
||||
|
||||
const credential = await getCredential(requestId, credentialId, authz.credentialOwnerUserId)
|
||||
if (!credential) {
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const credential = credentials[0]
|
||||
|
||||
// Check if the credential belongs to the user
|
||||
if (credential.userId !== session.user.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized credential access attempt`, {
|
||||
credentialUserId: credential.userId,
|
||||
requestUserId: session.user.id,
|
||||
})
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Refresh access token if needed using the utility function
|
||||
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credentialId,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
)
|
||||
|
||||
if (!accessToken) {
|
||||
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { db } from '@sim/db'
|
||||
import { webhook as webhookTable, workflow as workflowTable } from '@sim/db/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { and, eq, or } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { verifyCronAuth } from '@/lib/auth/internal'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
@@ -35,7 +35,15 @@ export async function GET(request: NextRequest) {
|
||||
})
|
||||
.from(webhookTable)
|
||||
.innerJoin(workflowTable, eq(webhookTable.workflowId, workflowTable.id))
|
||||
.where(and(eq(webhookTable.isActive, true), eq(webhookTable.provider, 'microsoftteams')))
|
||||
.where(
|
||||
and(
|
||||
eq(webhookTable.isActive, true),
|
||||
or(
|
||||
eq(webhookTable.provider, 'microsoft-teams'),
|
||||
eq(webhookTable.provider, 'microsoftteams')
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
`Found ${webhooksWithWorkflows.length} active Teams webhooks, checking for expiring subscriptions`
|
||||
|
||||
@@ -137,7 +137,7 @@ export async function POST(request: NextRequest) {
|
||||
const isCredentialBased = credentialBasedProviders.includes(provider)
|
||||
// Treat Microsoft Teams chat subscription as credential-based for path generation purposes
|
||||
const isMicrosoftTeamsChatSubscription =
|
||||
provider === 'microsoftteams' &&
|
||||
provider === 'microsoft-teams' &&
|
||||
typeof providerConfig === 'object' &&
|
||||
providerConfig?.triggerId === 'microsoftteams_chat_subscription'
|
||||
|
||||
@@ -297,7 +297,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
if (provider === 'microsoftteams') {
|
||||
if (provider === 'microsoft-teams') {
|
||||
const { createTeamsSubscription } = await import('@/lib/webhooks/webhook-helpers')
|
||||
logger.info(`[${requestId}] Creating Teams subscription before saving to database`)
|
||||
try {
|
||||
|
||||
@@ -441,7 +441,7 @@ export async function GET(request: NextRequest) {
|
||||
})
|
||||
}
|
||||
|
||||
case 'microsoftteams': {
|
||||
case 'microsoft-teams': {
|
||||
const hmacSecret = providerConfig.hmacSecret
|
||||
|
||||
if (!hmacSecret) {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { LoggingSession } from '@/lib/logs/execution/logging-session'
|
||||
import { generateRequestId } from '@/lib/utils'
|
||||
import {
|
||||
checkRateLimits,
|
||||
@@ -139,34 +137,10 @@ export async function POST(
|
||||
if (foundWebhook.blockId) {
|
||||
const blockExists = await blockExistsInDeployment(foundWorkflow.id, foundWebhook.blockId)
|
||||
if (!blockExists) {
|
||||
logger.warn(
|
||||
logger.info(
|
||||
`[${requestId}] Trigger block ${foundWebhook.blockId} not found in deployment for workflow ${foundWorkflow.id}`
|
||||
)
|
||||
|
||||
const executionId = uuidv4()
|
||||
const loggingSession = new LoggingSession(foundWorkflow.id, executionId, 'webhook', requestId)
|
||||
|
||||
const actorUserId = foundWorkflow.workspaceId
|
||||
? (await import('@/lib/workspaces/utils')).getWorkspaceBilledAccountUserId(
|
||||
foundWorkflow.workspaceId
|
||||
) || foundWorkflow.userId
|
||||
: foundWorkflow.userId
|
||||
|
||||
await loggingSession.safeStart({
|
||||
userId: actorUserId,
|
||||
workspaceId: foundWorkflow.workspaceId || '',
|
||||
variables: {},
|
||||
})
|
||||
|
||||
await loggingSession.safeCompleteWithError({
|
||||
error: {
|
||||
message: `Trigger block not deployed. The webhook trigger (block ${foundWebhook.blockId}) is not present in the deployed workflow. Please redeploy the workflow.`,
|
||||
stackTrace: undefined,
|
||||
},
|
||||
traceSpans: [],
|
||||
})
|
||||
|
||||
return new NextResponse('Trigger block not deployed', { status: 404 })
|
||||
return new NextResponse('Trigger block not found in deployment', { status: 404 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,9 +34,9 @@ import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getBaseUrl } from '@/lib/urls/utils'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { CredentialRequirement } from '@/lib/workflows/credential-extractor'
|
||||
import type { Template } from '@/app/templates/templates'
|
||||
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import { useStarTemplate, useTemplate } from '@/hooks/queries/templates'
|
||||
|
||||
const logger = createLogger('TemplateDetails')
|
||||
|
||||
@@ -52,16 +52,14 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
const workspaceId = isWorkspaceContext ? (params?.workspaceId as string) : null
|
||||
const { data: session } = useSession()
|
||||
|
||||
const [template, setTemplate] = useState<Template | null>(null)
|
||||
const { data: template, isLoading: loading } = useTemplate(templateId)
|
||||
const starTemplate = useStarTemplate()
|
||||
|
||||
const [currentUserOrgs, setCurrentUserOrgs] = useState<string[]>([])
|
||||
const [currentUserOrgRoles, setCurrentUserOrgRoles] = useState<
|
||||
Array<{ organizationId: string; role: string }>
|
||||
>([])
|
||||
const [isSuperUser, setIsSuperUser] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [isStarred, setIsStarred] = useState(false)
|
||||
const [starCount, setStarCount] = useState(0)
|
||||
const [isStarring, setIsStarring] = useState(false)
|
||||
const [isUsing, setIsUsing] = useState(false)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [isApproving, setIsApproving] = useState(false)
|
||||
@@ -76,29 +74,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
|
||||
const currentUserId = session?.user?.id || null
|
||||
|
||||
// Fetch template data on client side
|
||||
useEffect(() => {
|
||||
if (!templateId) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const fetchTemplate = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/templates/${templateId}`)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setTemplate(data.data)
|
||||
setIsStarred(data.data.isStarred || false)
|
||||
setStarCount(data.data.stars || 0)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching template:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchUserOrganizations = async () => {
|
||||
if (!currentUserId) return
|
||||
|
||||
@@ -134,12 +110,10 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
}
|
||||
}
|
||||
|
||||
fetchTemplate()
|
||||
fetchSuperUserStatus()
|
||||
fetchUserOrganizations()
|
||||
}, [templateId, currentUserId])
|
||||
}, [currentUserId])
|
||||
|
||||
// Fetch workspaces when user is logged in
|
||||
useEffect(() => {
|
||||
if (!currentUserId) return
|
||||
|
||||
@@ -149,7 +123,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
const response = await fetch('/api/workspaces')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
// Filter workspaces where user has write/admin permissions
|
||||
const availableWorkspaces = data.workspaces
|
||||
.filter((ws: any) => ws.permissions === 'write' || ws.permissions === 'admin')
|
||||
.map((ws: any) => ({
|
||||
@@ -169,7 +142,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
fetchWorkspaces()
|
||||
}, [currentUserId])
|
||||
|
||||
// Clean up URL when returning from login
|
||||
useEffect(() => {
|
||||
if (template && searchParams?.get('use') === 'true' && currentUserId) {
|
||||
if (isWorkspaceContext && workspaceId) {
|
||||
@@ -181,26 +153,20 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
}
|
||||
}, [searchParams, currentUserId, template, isWorkspaceContext, workspaceId, router])
|
||||
|
||||
// Check if user can edit template
|
||||
const canEditTemplate = (() => {
|
||||
if (!currentUserId || !template?.creator) return false
|
||||
|
||||
// For user creator profiles: must be the user themselves
|
||||
if (template.creator.referenceType === 'user') {
|
||||
return template.creator.referenceId === currentUserId
|
||||
}
|
||||
|
||||
// For organization creator profiles:
|
||||
if (template.creator.referenceType === 'organization' && template.creator.referenceId) {
|
||||
const isOrgMember = currentUserOrgs.includes(template.creator.referenceId)
|
||||
|
||||
// If template has a connected workflow, any org member with workspace access can edit
|
||||
if (template.workflowId) {
|
||||
return isOrgMember
|
||||
}
|
||||
|
||||
// If template is orphaned, only admin/owner can edit
|
||||
// We need to check the user's role in the organization
|
||||
const orgMembership = currentUserOrgRoles.find(
|
||||
(org) => org.organizationId === template.creator?.referenceId
|
||||
)
|
||||
@@ -212,7 +178,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
return false
|
||||
})()
|
||||
|
||||
// Check workspace access for connected workflow
|
||||
useEffect(() => {
|
||||
const checkWorkspaceAccess = async () => {
|
||||
if (!template?.workflowId || !currentUserId || !canEditTemplate) {
|
||||
@@ -227,7 +192,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
} else if (checkResponse.ok) {
|
||||
setHasWorkspaceAccess(true)
|
||||
} else {
|
||||
// Workflow doesn't exist
|
||||
setHasWorkspaceAccess(null)
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -319,32 +283,20 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
* @param event - The wheel event fired when the user scrolls over the preview area.
|
||||
*/
|
||||
const handleCanvasWheelCapture = (event: React.WheelEvent<HTMLDivElement>) => {
|
||||
// Allow pinch/zoom gestures (e.g., ctrl/cmd + wheel) to continue to the canvas.
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
return
|
||||
}
|
||||
|
||||
// Prevent React Flow from handling the wheel; let the page scroll naturally.
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const handleStarToggle = async () => {
|
||||
if (isStarring || !currentUserId) return
|
||||
if (!currentUserId || !template) return
|
||||
|
||||
setIsStarring(true)
|
||||
try {
|
||||
const method = isStarred ? 'DELETE' : 'POST'
|
||||
const response = await fetch(`/api/templates/${template.id}/star`, { method })
|
||||
|
||||
if (response.ok) {
|
||||
setIsStarred(!isStarred)
|
||||
setStarCount((prev) => (isStarred ? prev - 1 : prev + 1))
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error toggling star:', error)
|
||||
} finally {
|
||||
setIsStarring(false)
|
||||
}
|
||||
starTemplate.mutate({
|
||||
templateId: template.id,
|
||||
action: template.isStarred ? 'remove' : 'add',
|
||||
})
|
||||
}
|
||||
|
||||
const handleUseTemplate = () => {
|
||||
@@ -357,7 +309,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
return
|
||||
}
|
||||
|
||||
// In workspace context, use current workspace directly
|
||||
if (isWorkspaceContext && workspaceId) {
|
||||
handleWorkspaceSelectForUse(workspaceId)
|
||||
}
|
||||
@@ -366,7 +317,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
const handleEditTemplate = async () => {
|
||||
if (!currentUserId || !template) return
|
||||
|
||||
// In workspace context with existing workflow, navigate directly
|
||||
if (isWorkspaceContext && workspaceId && template.workflowId) {
|
||||
setIsEditing(true)
|
||||
try {
|
||||
@@ -381,10 +331,8 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
} finally {
|
||||
setIsEditing(false)
|
||||
}
|
||||
// If workflow doesn't exist, fall through to workspace selector
|
||||
}
|
||||
|
||||
// Check if workflow exists and user has access (global context)
|
||||
if (template.workflowId && !isWorkspaceContext) {
|
||||
setIsEditing(true)
|
||||
try {
|
||||
@@ -410,7 +358,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
}
|
||||
}
|
||||
|
||||
// Workflow doesn't exist - show workspace selector or use current workspace
|
||||
if (isWorkspaceContext && workspaceId) {
|
||||
handleWorkspaceSelectForEdit(workspaceId)
|
||||
} else {
|
||||
@@ -435,7 +382,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
|
||||
const { workflowId } = await response.json()
|
||||
|
||||
// Navigate to the new workflow with full page load
|
||||
window.location.href = `/workspace/${workspaceId}/w/${workflowId}`
|
||||
} catch (error) {
|
||||
logger.error('Error using template:', error)
|
||||
@@ -450,7 +396,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
setIsUsing(true)
|
||||
setShowWorkspaceSelectorForEdit(false)
|
||||
try {
|
||||
// Import template as a new workflow and connect it to the template
|
||||
const response = await fetch(`/api/templates/${template.id}/use`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -463,7 +408,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
|
||||
const { workflowId } = await response.json()
|
||||
|
||||
// Navigate to the new workflow with full page load
|
||||
window.location.href = `/workspace/${workspaceId}/w/${workflowId}`
|
||||
} catch (error) {
|
||||
logger.error('Error importing template for editing:', error)
|
||||
@@ -482,9 +426,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
// Update template status optimistically
|
||||
setTemplate({ ...template, status: 'approved' })
|
||||
// Redirect back to templates page after approval
|
||||
if (isWorkspaceContext && workspaceId) {
|
||||
router.push(`/workspace/${workspaceId}/templates`)
|
||||
} else {
|
||||
@@ -508,9 +449,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
// Update template status optimistically
|
||||
setTemplate({ ...template, status: 'rejected' })
|
||||
// Redirect back to templates page after rejection
|
||||
if (isWorkspaceContext && workspaceId) {
|
||||
router.push(`/workspace/${workspaceId}/templates`)
|
||||
} else {
|
||||
@@ -752,11 +690,11 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
onClick={handleStarToggle}
|
||||
className={cn(
|
||||
'h-[14px] w-[14px] cursor-pointer transition-colors',
|
||||
isStarred ? 'fill-yellow-500 text-yellow-500' : 'text-[#888888]',
|
||||
isStarring && 'opacity-50'
|
||||
template.isStarred ? 'fill-yellow-500 text-yellow-500' : 'text-[#888888]',
|
||||
starTemplate.isPending && 'opacity-50'
|
||||
)}
|
||||
/>
|
||||
<span className='font-medium text-[#888888] text-[14px]'>{starCount}</span>
|
||||
<span className='font-medium text-[#888888] text-[14px]'>{template.stars || 0}</span>
|
||||
|
||||
{/* Users icon and count */}
|
||||
<ChartNoAxesColumn className='h-[16px] w-[16px] text-[#888888]' />
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import { useStarTemplate } from '@/hooks/queries/templates'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('TemplateCard')
|
||||
@@ -12,37 +13,20 @@ const logger = createLogger('TemplateCard')
|
||||
interface TemplateCardProps {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
author: string
|
||||
authorImageUrl?: string | null
|
||||
usageCount: string
|
||||
stars?: number
|
||||
icon?: React.ReactNode | string
|
||||
iconColor?: string
|
||||
blocks?: string[]
|
||||
onClick?: () => void
|
||||
className?: string
|
||||
// Workflow state for rendering preview
|
||||
state?: WorkflowState
|
||||
isStarred?: boolean
|
||||
// Optional callback when template is successfully used (for closing modals, etc.)
|
||||
onTemplateUsed?: () => void
|
||||
// Callback when star state changes (for parent state updates)
|
||||
onStarChange?: (templateId: string, isStarred: boolean, newStarCount: number) => void
|
||||
// User authentication status
|
||||
isAuthenticated?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton component for loading states
|
||||
*/
|
||||
export function TemplateCardSkeleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={cn('h-[268px] w-full rounded-[8px] bg-[#202020] p-[8px]', className)}>
|
||||
{/* Workflow preview skeleton */}
|
||||
<div className='h-[180px] w-full animate-pulse rounded-[6px] bg-gray-700' />
|
||||
|
||||
{/* Title and blocks row skeleton */}
|
||||
<div className='mt-[14px] flex items-center justify-between'>
|
||||
<div className='h-4 w-32 animate-pulse rounded bg-gray-700' />
|
||||
<div className='flex items-center gap-[-4px]'>
|
||||
@@ -55,7 +39,6 @@ export function TemplateCardSkeleton({ className }: { className?: string }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Creator and stats row skeleton */}
|
||||
<div className='mt-[14px] flex items-center justify-between'>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<div className='h-[14px] w-[14px] animate-pulse rounded-full bg-gray-700' />
|
||||
@@ -72,31 +55,23 @@ export function TemplateCardSkeleton({ className }: { className?: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
// Utility function to extract block types from workflow state
|
||||
const extractBlockTypesFromState = (state?: {
|
||||
blocks?: Record<string, { type: string; name?: string }>
|
||||
}): string[] => {
|
||||
if (!state?.blocks) return []
|
||||
|
||||
// Get unique block types from the state, excluding starter blocks
|
||||
// Sort the keys to ensure consistent ordering between server and client
|
||||
const blockTypes = Object.keys(state.blocks)
|
||||
.sort() // Sort keys to ensure consistent order
|
||||
.sort()
|
||||
.map((key) => state.blocks![key].type)
|
||||
.filter((type) => type !== 'starter')
|
||||
return [...new Set(blockTypes)]
|
||||
}
|
||||
|
||||
// Utility function to get the full block config for colored icon display
|
||||
const getBlockConfig = (blockType: string) => {
|
||||
const block = getBlock(blockType)
|
||||
return block
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an arbitrary workflow-like object into a valid WorkflowState for preview rendering.
|
||||
* Ensures required fields exist: blocks with required properties, edges array, loops and parallels maps.
|
||||
*/
|
||||
function normalizeWorkflowState(input?: any): WorkflowState | null {
|
||||
if (!input || !input.blocks) return null
|
||||
|
||||
@@ -142,34 +117,22 @@ function normalizeWorkflowState(input?: any): WorkflowState | null {
|
||||
function TemplateCardInner({
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
author,
|
||||
authorImageUrl,
|
||||
usageCount,
|
||||
stars = 0,
|
||||
icon,
|
||||
iconColor = 'bg-blue-500',
|
||||
blocks = [],
|
||||
onClick,
|
||||
className,
|
||||
state,
|
||||
isStarred = false,
|
||||
onTemplateUsed,
|
||||
onStarChange,
|
||||
isAuthenticated = true,
|
||||
}: TemplateCardProps) {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
|
||||
// Local state for optimistic updates
|
||||
const [localIsStarred, setLocalIsStarred] = useState(isStarred)
|
||||
const [localStarCount, setLocalStarCount] = useState(stars)
|
||||
const [isStarLoading, setIsStarLoading] = useState(false)
|
||||
const { mutate: toggleStar, isPending: isStarLoading } = useStarTemplate()
|
||||
|
||||
// Memoize normalized workflow state to avoid recalculation on every render
|
||||
const normalizedState = useMemo(() => normalizeWorkflowState(state), [state])
|
||||
|
||||
// Use IntersectionObserver to defer rendering the heavy WorkflowPreview until in viewport
|
||||
const previewRef = useRef<HTMLDivElement | null>(null)
|
||||
const [isInView, setIsInView] = useState(false)
|
||||
|
||||
@@ -188,9 +151,6 @@ function TemplateCardInner({
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
// Extract block types from state if provided, otherwise use the blocks prop
|
||||
// Filter out starter blocks in both cases and sort for consistent rendering
|
||||
// Memoized to prevent recalculation on every render
|
||||
const blockTypes = useMemo(
|
||||
() =>
|
||||
state
|
||||
@@ -199,65 +159,16 @@ function TemplateCardInner({
|
||||
[state, blocks]
|
||||
)
|
||||
|
||||
// Handle star toggle with optimistic updates
|
||||
const handleStarClick = async (e: React.MouseEvent) => {
|
||||
const handleStarClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
|
||||
// Prevent multiple clicks while loading
|
||||
if (isStarLoading) return
|
||||
|
||||
setIsStarLoading(true)
|
||||
|
||||
// Optimistic update - update UI immediately
|
||||
const newIsStarred = !localIsStarred
|
||||
const newStarCount = newIsStarred ? localStarCount + 1 : localStarCount - 1
|
||||
|
||||
setLocalIsStarred(newIsStarred)
|
||||
setLocalStarCount(newStarCount)
|
||||
|
||||
// Notify parent component immediately for optimistic update
|
||||
if (onStarChange) {
|
||||
onStarChange(id, newIsStarred, newStarCount)
|
||||
}
|
||||
|
||||
try {
|
||||
const method = localIsStarred ? 'DELETE' : 'POST'
|
||||
const response = await fetch(`/api/templates/${id}/star`, { method })
|
||||
|
||||
if (!response.ok) {
|
||||
// Rollback on error
|
||||
setLocalIsStarred(localIsStarred)
|
||||
setLocalStarCount(localStarCount)
|
||||
|
||||
// Rollback parent state too
|
||||
if (onStarChange) {
|
||||
onStarChange(id, localIsStarred, localStarCount)
|
||||
}
|
||||
|
||||
logger.error('Failed to toggle star:', response.statusText)
|
||||
}
|
||||
} catch (error) {
|
||||
// Rollback on error
|
||||
setLocalIsStarred(localIsStarred)
|
||||
setLocalStarCount(localStarCount)
|
||||
|
||||
// Rollback parent state too
|
||||
if (onStarChange) {
|
||||
onStarChange(id, localIsStarred, localStarCount)
|
||||
}
|
||||
|
||||
logger.error('Error toggling star:', error)
|
||||
} finally {
|
||||
setIsStarLoading(false)
|
||||
}
|
||||
toggleStar({
|
||||
templateId: id,
|
||||
action: isStarred ? 'remove' : 'add',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate template detail page URL based on context.
|
||||
* If we're in a workspace context, navigate to the workspace template page.
|
||||
* Otherwise, navigate to the global template page.
|
||||
* Memoized to avoid recalculation on every render.
|
||||
*/
|
||||
const templateUrl = useMemo(() => {
|
||||
const workspaceId = params?.workspaceId as string | undefined
|
||||
if (workspaceId) {
|
||||
@@ -266,23 +177,8 @@ function TemplateCardInner({
|
||||
return `/templates/${id}`
|
||||
}, [params?.workspaceId, id])
|
||||
|
||||
/**
|
||||
* Handle use button click - navigate to template detail page
|
||||
*/
|
||||
const handleUseClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
router.push(templateUrl)
|
||||
},
|
||||
[router, templateUrl]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handle card click - navigate to template detail page
|
||||
*/
|
||||
const handleCardClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
// Don't navigate if clicking on action buttons
|
||||
const target = e.target as HTMLElement
|
||||
if (target.closest('button') || target.closest('[data-action]')) {
|
||||
return
|
||||
@@ -298,7 +194,6 @@ function TemplateCardInner({
|
||||
onClick={handleCardClick}
|
||||
className={cn('w-full cursor-pointer rounded-[8px] bg-[#202020] p-[8px]', className)}
|
||||
>
|
||||
{/* Workflow Preview */}
|
||||
<div
|
||||
ref={previewRef}
|
||||
className='pointer-events-none h-[180px] w-full overflow-hidden rounded-[6px]'
|
||||
@@ -318,16 +213,12 @@ function TemplateCardInner({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title and Blocks Row */}
|
||||
<div className='mt-[10px] flex items-center justify-between'>
|
||||
{/* Template Name */}
|
||||
<h3 className='truncate pr-[8px] pl-[2px] font-medium text-[16px] text-white'>{title}</h3>
|
||||
|
||||
{/* Block Icons */}
|
||||
<div className='flex flex-shrink-0'>
|
||||
{blockTypes.length > 4 ? (
|
||||
<>
|
||||
{/* Show first 3 blocks when there are more than 4 */}
|
||||
{blockTypes.slice(0, 3).map((blockType, index) => {
|
||||
const blockConfig = getBlockConfig(blockType)
|
||||
if (!blockConfig) return null
|
||||
@@ -345,7 +236,6 @@ function TemplateCardInner({
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{/* Show +n for remaining blocks */}
|
||||
<div
|
||||
className='flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-[4px] bg-[#4A4A4A]'
|
||||
style={{ marginLeft: '-4px' }}
|
||||
@@ -354,7 +244,6 @@ function TemplateCardInner({
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
/* Show all blocks when 4 or fewer */
|
||||
blockTypes.map((blockType, index) => {
|
||||
const blockConfig = getBlockConfig(blockType)
|
||||
if (!blockConfig) return null
|
||||
@@ -376,9 +265,7 @@ function TemplateCardInner({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Creator and Stats Row */}
|
||||
<div className='mt-[10px] flex items-center justify-between'>
|
||||
{/* Creator Info */}
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
{authorImageUrl ? (
|
||||
<div className='h-[26px] w-[26px] flex-shrink-0 overflow-hidden rounded-full'>
|
||||
@@ -392,7 +279,6 @@ function TemplateCardInner({
|
||||
<span className='truncate font-medium text-[#888888] text-[12px]'>{author}</span>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className='flex flex-shrink-0 items-center gap-[6px] font-medium text-[#888888] text-[12px]'>
|
||||
<User className='h-[12px] w-[12px]' />
|
||||
<span>{usageCount}</span>
|
||||
@@ -400,11 +286,11 @@ function TemplateCardInner({
|
||||
onClick={handleStarClick}
|
||||
className={cn(
|
||||
'h-[12px] w-[12px] cursor-pointer transition-colors',
|
||||
localIsStarred ? 'fill-yellow-500 text-yellow-500' : 'text-[#888888]',
|
||||
isStarred ? 'fill-yellow-500 text-yellow-500' : 'text-[#888888]',
|
||||
isStarLoading && 'opacity-50'
|
||||
)}
|
||||
/>
|
||||
<span>{localStarCount}</span>
|
||||
<span>{stars}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Input } from '@/components/ui/input'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { CredentialRequirement } from '@/lib/workflows/credential-extractor'
|
||||
import { TemplateCard, TemplateCardSkeleton } from '@/app/templates/components/template-card'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
import type { CreatorProfileDetails } from '@/types/creator-profile'
|
||||
|
||||
@@ -55,11 +56,11 @@ export default function Templates({
|
||||
}: TemplatesProps) {
|
||||
const router = useRouter()
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 300)
|
||||
const [activeTab, setActiveTab] = useState('gallery')
|
||||
const [templates, setTemplates] = useState<Template[]>(initialTemplates)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// Redirect authenticated users to workspace templates
|
||||
useEffect(() => {
|
||||
if (currentUserId) {
|
||||
const redirectToWorkspace = async () => {
|
||||
@@ -80,32 +81,19 @@ export default function Templates({
|
||||
}
|
||||
}, [currentUserId, router])
|
||||
|
||||
/**
|
||||
* Update star status for a template
|
||||
*/
|
||||
const handleStarChange = (templateId: string, isStarred: boolean, newStarCount: number) => {
|
||||
setTemplates((prevTemplates) =>
|
||||
prevTemplates.map((template) =>
|
||||
template.id === templateId ? { ...template, isStarred, stars: newStarCount } : template
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter templates based on active tab and search query
|
||||
* Memoized to prevent unnecessary recalculations on render
|
||||
*/
|
||||
const filteredTemplates = useMemo(() => {
|
||||
const query = searchQuery.toLowerCase()
|
||||
const query = debouncedSearchQuery.toLowerCase()
|
||||
|
||||
return templates.filter((template) => {
|
||||
// Filter by tab - only gallery and pending for public page
|
||||
const tabMatch =
|
||||
activeTab === 'gallery' ? template.status === 'approved' : template.status === 'pending'
|
||||
|
||||
if (!tabMatch) return false
|
||||
|
||||
// Filter by search query
|
||||
if (!query) return true
|
||||
|
||||
const searchableText = [template.name, template.details?.tagline, template.creator?.name]
|
||||
@@ -115,14 +103,14 @@ export default function Templates({
|
||||
|
||||
return searchableText.includes(query)
|
||||
})
|
||||
}, [templates, activeTab, searchQuery])
|
||||
}, [templates, activeTab, debouncedSearchQuery])
|
||||
|
||||
/**
|
||||
* Get empty state message based on current filters
|
||||
* Memoized to prevent unnecessary recalculations on render
|
||||
*/
|
||||
const emptyState = useMemo(() => {
|
||||
if (searchQuery) {
|
||||
if (debouncedSearchQuery) {
|
||||
return {
|
||||
title: 'No templates found',
|
||||
description: 'Try a different search term',
|
||||
@@ -141,7 +129,7 @@ export default function Templates({
|
||||
}
|
||||
|
||||
return messages[activeTab as keyof typeof messages] || messages.gallery
|
||||
}, [searchQuery, activeTab])
|
||||
}, [debouncedSearchQuery, activeTab])
|
||||
|
||||
return (
|
||||
<div className='flex h-[100vh] flex-col'>
|
||||
@@ -209,15 +197,12 @@ export default function Templates({
|
||||
key={template.id}
|
||||
id={template.id}
|
||||
title={template.name}
|
||||
description={template.details?.tagline || ''}
|
||||
author={template.creator?.name || 'Unknown'}
|
||||
authorImageUrl={template.creator?.profileImageUrl || null}
|
||||
usageCount={template.views.toString()}
|
||||
stars={template.stars}
|
||||
state={template.state}
|
||||
isStarred={template.isStarred}
|
||||
onStarChange={handleStarChange}
|
||||
isAuthenticated={!!currentUserId}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import { useStarTemplate } from '@/hooks/queries/templates'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('TemplateCard')
|
||||
@@ -12,37 +13,21 @@ const logger = createLogger('TemplateCard')
|
||||
interface TemplateCardProps {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
author: string
|
||||
authorImageUrl?: string | null
|
||||
usageCount: string
|
||||
stars?: number
|
||||
icon?: React.ReactNode | string
|
||||
iconColor?: string
|
||||
blocks?: string[]
|
||||
onClick?: () => void
|
||||
className?: string
|
||||
// Workflow state for rendering preview
|
||||
state?: WorkflowState
|
||||
isStarred?: boolean
|
||||
// Optional callback when template is successfully used (for closing modals, etc.)
|
||||
onTemplateUsed?: () => void
|
||||
// Callback when star state changes (for parent state updates)
|
||||
onStarChange?: (templateId: string, isStarred: boolean, newStarCount: number) => void
|
||||
// User authentication status
|
||||
isAuthenticated?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton component for loading states
|
||||
*/
|
||||
export function TemplateCardSkeleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={cn('h-[268px] w-full rounded-[8px] bg-[#202020] p-[8px]', className)}>
|
||||
{/* Workflow preview skeleton */}
|
||||
<div className='h-[180px] w-full animate-pulse rounded-[6px] bg-gray-700' />
|
||||
|
||||
{/* Title and blocks row skeleton */}
|
||||
<div className='mt-[14px] flex items-center justify-between'>
|
||||
<div className='h-4 w-32 animate-pulse rounded bg-gray-700' />
|
||||
<div className='flex items-center gap-[-4px]'>
|
||||
@@ -55,7 +40,6 @@ export function TemplateCardSkeleton({ className }: { className?: string }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Creator and stats row skeleton */}
|
||||
<div className='mt-[14px] flex items-center justify-between'>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<div className='h-[14px] w-[14px] animate-pulse rounded-full bg-gray-700' />
|
||||
@@ -72,31 +56,23 @@ export function TemplateCardSkeleton({ className }: { className?: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
// Utility function to extract block types from workflow state
|
||||
const extractBlockTypesFromState = (state?: {
|
||||
blocks?: Record<string, { type: string; name?: string }>
|
||||
}): string[] => {
|
||||
if (!state?.blocks) return []
|
||||
|
||||
// Get unique block types from the state, excluding starter blocks
|
||||
// Sort the keys to ensure consistent ordering between server and client
|
||||
const blockTypes = Object.keys(state.blocks)
|
||||
.sort() // Sort keys to ensure consistent order
|
||||
.sort()
|
||||
.map((key) => state.blocks![key].type)
|
||||
.filter((type) => type !== 'starter')
|
||||
return [...new Set(blockTypes)]
|
||||
}
|
||||
|
||||
// Utility function to get the full block config for colored icon display
|
||||
const getBlockConfig = (blockType: string) => {
|
||||
const block = getBlock(blockType)
|
||||
return block
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an arbitrary workflow-like object into a valid WorkflowState for preview rendering.
|
||||
* Ensures required fields exist: blocks with required properties, edges array, loops and parallels maps.
|
||||
*/
|
||||
function normalizeWorkflowState(input?: any): WorkflowState | null {
|
||||
if (!input || !input.blocks) return null
|
||||
|
||||
@@ -142,34 +118,22 @@ function normalizeWorkflowState(input?: any): WorkflowState | null {
|
||||
function TemplateCardInner({
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
author,
|
||||
authorImageUrl,
|
||||
usageCount,
|
||||
stars = 0,
|
||||
icon,
|
||||
iconColor = 'bg-blue-500',
|
||||
blocks = [],
|
||||
onClick,
|
||||
className,
|
||||
state,
|
||||
isStarred = false,
|
||||
onTemplateUsed,
|
||||
onStarChange,
|
||||
isAuthenticated = true,
|
||||
}: TemplateCardProps) {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
|
||||
// Local state for optimistic updates
|
||||
const [localIsStarred, setLocalIsStarred] = useState(isStarred)
|
||||
const [localStarCount, setLocalStarCount] = useState(stars)
|
||||
const [isStarLoading, setIsStarLoading] = useState(false)
|
||||
const { mutate: toggleStar, isPending: isStarLoading } = useStarTemplate()
|
||||
|
||||
// Memoize normalized workflow state to avoid recalculation on every render
|
||||
const normalizedState = useMemo(() => normalizeWorkflowState(state), [state])
|
||||
|
||||
// Use IntersectionObserver to defer rendering the heavy WorkflowPreview until in viewport
|
||||
const previewRef = useRef<HTMLDivElement | null>(null)
|
||||
const [isInView, setIsInView] = useState(false)
|
||||
|
||||
@@ -188,9 +152,6 @@ function TemplateCardInner({
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
// Extract block types from state if provided, otherwise use the blocks prop
|
||||
// Filter out starter blocks in both cases and sort for consistent rendering
|
||||
// Memoized to prevent recalculation on every render
|
||||
const blockTypes = useMemo(
|
||||
() =>
|
||||
state
|
||||
@@ -199,65 +160,16 @@ function TemplateCardInner({
|
||||
[state, blocks]
|
||||
)
|
||||
|
||||
// Handle star toggle with optimistic updates
|
||||
const handleStarClick = async (e: React.MouseEvent) => {
|
||||
const handleStarClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
|
||||
// Prevent multiple clicks while loading
|
||||
if (isStarLoading) return
|
||||
|
||||
setIsStarLoading(true)
|
||||
|
||||
// Optimistic update - update UI immediately
|
||||
const newIsStarred = !localIsStarred
|
||||
const newStarCount = newIsStarred ? localStarCount + 1 : localStarCount - 1
|
||||
|
||||
setLocalIsStarred(newIsStarred)
|
||||
setLocalStarCount(newStarCount)
|
||||
|
||||
// Notify parent component immediately for optimistic update
|
||||
if (onStarChange) {
|
||||
onStarChange(id, newIsStarred, newStarCount)
|
||||
}
|
||||
|
||||
try {
|
||||
const method = localIsStarred ? 'DELETE' : 'POST'
|
||||
const response = await fetch(`/api/templates/${id}/star`, { method })
|
||||
|
||||
if (!response.ok) {
|
||||
// Rollback on error
|
||||
setLocalIsStarred(localIsStarred)
|
||||
setLocalStarCount(localStarCount)
|
||||
|
||||
// Rollback parent state too
|
||||
if (onStarChange) {
|
||||
onStarChange(id, localIsStarred, localStarCount)
|
||||
}
|
||||
|
||||
logger.error('Failed to toggle star:', response.statusText)
|
||||
}
|
||||
} catch (error) {
|
||||
// Rollback on error
|
||||
setLocalIsStarred(localIsStarred)
|
||||
setLocalStarCount(localStarCount)
|
||||
|
||||
// Rollback parent state too
|
||||
if (onStarChange) {
|
||||
onStarChange(id, localIsStarred, localStarCount)
|
||||
}
|
||||
|
||||
logger.error('Error toggling star:', error)
|
||||
} finally {
|
||||
setIsStarLoading(false)
|
||||
}
|
||||
toggleStar({
|
||||
templateId: id,
|
||||
action: isStarred ? 'remove' : 'add',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate template detail page URL based on context.
|
||||
* If we're in a workspace context, navigate to the workspace template page.
|
||||
* Otherwise, navigate to the global template page.
|
||||
* Memoized to avoid recalculation on every render.
|
||||
*/
|
||||
const templateUrl = useMemo(() => {
|
||||
const workspaceId = params?.workspaceId as string | undefined
|
||||
if (workspaceId) {
|
||||
@@ -266,23 +178,8 @@ function TemplateCardInner({
|
||||
return `/templates/${id}`
|
||||
}, [params?.workspaceId, id])
|
||||
|
||||
/**
|
||||
* Handle use button click - navigate to template detail page
|
||||
*/
|
||||
const handleUseClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
router.push(templateUrl)
|
||||
},
|
||||
[router, templateUrl]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handle card click - navigate to template detail page
|
||||
*/
|
||||
const handleCardClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
// Don't navigate if clicking on action buttons
|
||||
const target = e.target as HTMLElement
|
||||
if (target.closest('button') || target.closest('[data-action]')) {
|
||||
return
|
||||
@@ -298,7 +195,6 @@ function TemplateCardInner({
|
||||
onClick={handleCardClick}
|
||||
className={cn('w-full cursor-pointer rounded-[8px] bg-[#202020] p-[8px]', className)}
|
||||
>
|
||||
{/* Workflow Preview */}
|
||||
<div
|
||||
ref={previewRef}
|
||||
className='pointer-events-none h-[180px] w-full overflow-hidden rounded-[6px]'
|
||||
@@ -318,16 +214,12 @@ function TemplateCardInner({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title and Blocks Row */}
|
||||
<div className='mt-[10px] flex items-center justify-between'>
|
||||
{/* Template Name */}
|
||||
<h3 className='truncate pr-[8px] pl-[2px] font-medium text-[16px] text-white'>{title}</h3>
|
||||
|
||||
{/* Block Icons */}
|
||||
<div className='flex flex-shrink-0'>
|
||||
{blockTypes.length > 4 ? (
|
||||
<>
|
||||
{/* Show first 3 blocks when there are more than 4 */}
|
||||
{blockTypes.slice(0, 3).map((blockType, index) => {
|
||||
const blockConfig = getBlockConfig(blockType)
|
||||
if (!blockConfig) return null
|
||||
@@ -345,7 +237,6 @@ function TemplateCardInner({
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{/* Show +n for remaining blocks */}
|
||||
<div
|
||||
className='flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-[4px] bg-[#4A4A4A]'
|
||||
style={{ marginLeft: '-4px' }}
|
||||
@@ -354,7 +245,6 @@ function TemplateCardInner({
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
/* Show all blocks when 4 or fewer */
|
||||
blockTypes.map((blockType, index) => {
|
||||
const blockConfig = getBlockConfig(blockType)
|
||||
if (!blockConfig) return null
|
||||
@@ -376,9 +266,7 @@ function TemplateCardInner({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Creator and Stats Row */}
|
||||
<div className='mt-[10px] flex items-center justify-between'>
|
||||
{/* Creator Info */}
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
{authorImageUrl ? (
|
||||
<div className='h-[26px] w-[26px] flex-shrink-0 overflow-hidden rounded-full'>
|
||||
@@ -392,7 +280,6 @@ function TemplateCardInner({
|
||||
<span className='truncate font-medium text-[#888888] text-[12px]'>{author}</span>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className='flex flex-shrink-0 items-center gap-[6px] font-medium text-[#888888] text-[12px]'>
|
||||
<User className='h-[12px] w-[12px]' />
|
||||
<span>{usageCount}</span>
|
||||
@@ -400,11 +287,11 @@ function TemplateCardInner({
|
||||
onClick={handleStarClick}
|
||||
className={cn(
|
||||
'h-[12px] w-[12px] cursor-pointer transition-colors',
|
||||
localIsStarred ? 'fill-yellow-500 text-yellow-500' : 'text-[#888888]',
|
||||
isStarred ? 'fill-yellow-500 text-yellow-500' : 'text-[#888888]',
|
||||
isStarLoading && 'opacity-50'
|
||||
)}
|
||||
/>
|
||||
<span>{localStarCount}</span>
|
||||
<span>{stars}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
TemplateCard,
|
||||
TemplateCardSkeleton,
|
||||
} from '@/app/workspace/[workspaceId]/templates/components/template-card'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
import type { CreatorProfileDetails } from '@/types/creator-profile'
|
||||
|
||||
@@ -70,30 +71,19 @@ export default function Templates({
|
||||
isSuperUser,
|
||||
}: TemplatesProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 300)
|
||||
const [activeTab, setActiveTab] = useState('gallery')
|
||||
const [templates, setTemplates] = useState<Template[]>(initialTemplates)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
/**
|
||||
* Update star status for a template
|
||||
*/
|
||||
const handleStarChange = (templateId: string, isStarred: boolean, newStarCount: number) => {
|
||||
setTemplates((prevTemplates) =>
|
||||
prevTemplates.map((template) =>
|
||||
template.id === templateId ? { ...template, isStarred, stars: newStarCount } : template
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter templates based on active tab and search query
|
||||
* Memoized to prevent unnecessary recalculations on render
|
||||
*/
|
||||
const filteredTemplates = useMemo(() => {
|
||||
const query = searchQuery.toLowerCase()
|
||||
const query = debouncedSearchQuery.toLowerCase()
|
||||
|
||||
return templates.filter((template) => {
|
||||
// Filter by tab
|
||||
const tabMatch =
|
||||
activeTab === 'your'
|
||||
? template.userId === currentUserId || template.isStarred
|
||||
@@ -103,7 +93,6 @@ export default function Templates({
|
||||
|
||||
if (!tabMatch) return false
|
||||
|
||||
// Filter by search query
|
||||
if (!query) return true
|
||||
|
||||
const searchableText = [
|
||||
@@ -119,14 +108,14 @@ export default function Templates({
|
||||
|
||||
return searchableText.includes(query)
|
||||
})
|
||||
}, [templates, activeTab, searchQuery, currentUserId])
|
||||
}, [templates, activeTab, debouncedSearchQuery, currentUserId])
|
||||
|
||||
/**
|
||||
* Get empty state message based on current filters
|
||||
* Memoized to prevent unnecessary recalculations on render
|
||||
*/
|
||||
const emptyState = useMemo(() => {
|
||||
if (searchQuery) {
|
||||
if (debouncedSearchQuery) {
|
||||
return {
|
||||
title: 'No templates found',
|
||||
description: 'Try a different search term',
|
||||
@@ -149,7 +138,7 @@ export default function Templates({
|
||||
}
|
||||
|
||||
return messages[activeTab as keyof typeof messages] || messages.gallery
|
||||
}, [searchQuery, activeTab])
|
||||
}, [debouncedSearchQuery, activeTab])
|
||||
|
||||
return (
|
||||
<div className='flex h-[100vh] flex-col pl-64'>
|
||||
@@ -228,17 +217,12 @@ export default function Templates({
|
||||
key={template.id}
|
||||
id={template.id}
|
||||
title={template.name}
|
||||
description={template.description || template.details?.tagline || ''}
|
||||
author={author}
|
||||
authorImageUrl={authorImageUrl}
|
||||
usageCount={template.views.toString()}
|
||||
stars={template.stars}
|
||||
icon={template.icon}
|
||||
iconColor={template.color}
|
||||
state={template.state}
|
||||
isStarred={template.isStarred}
|
||||
onStarChange={handleStarChange}
|
||||
isAuthenticated={true}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -27,6 +27,12 @@ import { TagInput } from '@/components/ui/tag-input'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
|
||||
import {
|
||||
useCreateTemplate,
|
||||
useDeleteTemplate,
|
||||
useTemplateByWorkflow,
|
||||
useUpdateTemplate,
|
||||
} from '@/hooks/queries/templates'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('TemplateDeploy')
|
||||
@@ -55,15 +61,16 @@ interface TemplateDeployProps {
|
||||
|
||||
export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDeployProps) {
|
||||
const { data: session } = useSession()
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [existingTemplate, setExistingTemplate] = useState<any>(null)
|
||||
const [isLoadingTemplate, setIsLoadingTemplate] = useState(false)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [creatorOptions, setCreatorOptions] = useState<CreatorOption[]>([])
|
||||
const [loadingCreators, setLoadingCreators] = useState(false)
|
||||
const [showPreviewDialog, setShowPreviewDialog] = useState(false)
|
||||
|
||||
const { data: existingTemplate, isLoading: isLoadingTemplate } = useTemplateByWorkflow(workflowId)
|
||||
const createMutation = useCreateTemplate()
|
||||
const updateMutation = useUpdateTemplate()
|
||||
const deleteMutation = useDeleteTemplate()
|
||||
|
||||
const form = useForm<TemplateFormData>({
|
||||
resolver: zodResolver(templateSchema),
|
||||
defaultValues: {
|
||||
@@ -75,7 +82,6 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
|
||||
},
|
||||
})
|
||||
|
||||
// Fetch creator profiles
|
||||
const fetchCreatorOptions = async () => {
|
||||
if (!session?.user?.id) return
|
||||
|
||||
@@ -105,7 +111,6 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
|
||||
fetchCreatorOptions()
|
||||
}, [session?.user?.id])
|
||||
|
||||
// Auto-select creator profile when there's only one option and no selection yet
|
||||
useEffect(() => {
|
||||
const currentCreatorId = form.getValues('creatorId')
|
||||
if (creatorOptions.length === 1 && !currentCreatorId) {
|
||||
@@ -114,15 +119,12 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
|
||||
}
|
||||
}, [creatorOptions, form])
|
||||
|
||||
// Listen for creator profile saved event
|
||||
useEffect(() => {
|
||||
const handleCreatorProfileSaved = async () => {
|
||||
logger.info('Creator profile saved, refreshing profiles...')
|
||||
|
||||
// Refetch creator profiles (autoselection will happen via the effect above)
|
||||
await fetchCreatorOptions()
|
||||
|
||||
// Close settings modal and reopen deploy modal to template tab
|
||||
window.dispatchEvent(new CustomEvent('close-settings'))
|
||||
setTimeout(() => {
|
||||
window.dispatchEvent(new CustomEvent('open-deploy-modal', { detail: { tab: 'template' } }))
|
||||
@@ -136,41 +138,20 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Check for existing template
|
||||
useEffect(() => {
|
||||
const checkExistingTemplate = async () => {
|
||||
setIsLoadingTemplate(true)
|
||||
try {
|
||||
const response = await fetch(`/api/templates?workflowId=${workflowId}&limit=1`)
|
||||
if (response.ok) {
|
||||
const result = await response.json()
|
||||
const template = result.data?.[0] || null
|
||||
setExistingTemplate(template)
|
||||
if (existingTemplate) {
|
||||
const tagline = existingTemplate.details?.tagline || ''
|
||||
const about = existingTemplate.details?.about || ''
|
||||
|
||||
if (template) {
|
||||
// Map old template format to new format if needed
|
||||
const tagline = (template.details as any)?.tagline || template.description || ''
|
||||
const about = (template.details as any)?.about || ''
|
||||
|
||||
form.reset({
|
||||
name: template.name,
|
||||
tagline: tagline,
|
||||
about: about,
|
||||
creatorId: template.creatorId || undefined,
|
||||
tags: template.tags || [],
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error checking existing template:', error)
|
||||
setExistingTemplate(null)
|
||||
} finally {
|
||||
setIsLoadingTemplate(false)
|
||||
}
|
||||
form.reset({
|
||||
name: existingTemplate.name,
|
||||
tagline: tagline,
|
||||
about: about,
|
||||
creatorId: existingTemplate.creatorId || undefined,
|
||||
tags: existingTemplate.tags || [],
|
||||
})
|
||||
}
|
||||
|
||||
checkExistingTemplate()
|
||||
}, [workflowId, session?.user?.id])
|
||||
}, [existingTemplate, form])
|
||||
|
||||
const onSubmit = async (data: TemplateFormData) => {
|
||||
if (!session?.user) {
|
||||
@@ -178,85 +159,51 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
// Build template data with new schema
|
||||
const templateData: any = {
|
||||
const templateData = {
|
||||
name: data.name,
|
||||
details: {
|
||||
tagline: data.tagline || '',
|
||||
about: data.about || '',
|
||||
},
|
||||
creatorId: data.creatorId || null,
|
||||
creatorId: data.creatorId || undefined,
|
||||
tags: data.tags || [],
|
||||
}
|
||||
|
||||
let response
|
||||
if (existingTemplate) {
|
||||
// Update template metadata AND state from current workflow
|
||||
response = await fetch(`/api/templates/${existingTemplate.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
await updateMutation.mutateAsync({
|
||||
id: existingTemplate.id,
|
||||
data: {
|
||||
...templateData,
|
||||
updateState: true, // Update state from current workflow
|
||||
}),
|
||||
updateState: true,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
// Create new template with workflowId
|
||||
response = await fetch('/api/templates', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...templateData, workflowId }),
|
||||
})
|
||||
await createMutation.mutateAsync({ ...templateData, workflowId })
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(
|
||||
errorData.error || `Failed to ${existingTemplate ? 'update' : 'create'} template`
|
||||
)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
logger.info(`Template ${existingTemplate ? 'updated' : 'created'} successfully:`, result)
|
||||
|
||||
// Update existing template state
|
||||
setExistingTemplate(result.data || result)
|
||||
|
||||
logger.info(`Template ${existingTemplate ? 'updated' : 'created'} successfully`)
|
||||
onDeploymentComplete?.()
|
||||
} catch (error) {
|
||||
logger.error('Failed to save template:', error)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!existingTemplate) return
|
||||
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
const response = await fetch(`/api/templates/${existingTemplate.id}`, {
|
||||
method: 'DELETE',
|
||||
await deleteMutation.mutateAsync(existingTemplate.id)
|
||||
setShowDeleteDialog(false)
|
||||
form.reset({
|
||||
name: '',
|
||||
tagline: '',
|
||||
about: '',
|
||||
creatorId: undefined,
|
||||
tags: [],
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
setExistingTemplate(null)
|
||||
setShowDeleteDialog(false)
|
||||
form.reset({
|
||||
name: '',
|
||||
tagline: '',
|
||||
about: '',
|
||||
creatorId: undefined,
|
||||
tags: [],
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error deleting template:', error)
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -422,7 +369,7 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
|
||||
onChange={field.onChange}
|
||||
placeholder='Type and press Enter to add tags'
|
||||
maxTags={10}
|
||||
disabled={isSubmitting}
|
||||
disabled={createMutation.isPending || updateMutation.isPending}
|
||||
/>
|
||||
</FormControl>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
@@ -447,9 +394,11 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
|
||||
<Button
|
||||
type='submit'
|
||||
variant='primary'
|
||||
disabled={isSubmitting || !form.formState.isValid}
|
||||
disabled={
|
||||
createMutation.isPending || updateMutation.isPending || !form.formState.isValid
|
||||
}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
{createMutation.isPending || updateMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className='mr-[8px] h-[14px] w-[14px] animate-spin' />
|
||||
{existingTemplate ? 'Updating...' : 'Publishing...'}
|
||||
@@ -479,10 +428,10 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
disabled={deleteMutation.isPending}
|
||||
className='bg-red-600 text-white hover:bg-red-700'
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -511,7 +460,6 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
|
||||
)
|
||||
}
|
||||
|
||||
// Ensure the state has the right structure
|
||||
const workflowState: WorkflowState = {
|
||||
blocks: existingTemplate.state.blocks || {},
|
||||
edges: existingTemplate.state.edges || [],
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export { DeployModal } from './deploy-modal/deploy-modal'
|
||||
export { DeploymentControls } from './deployment-controls/deployment-controls'
|
||||
export { ExportControls } from './export-controls/export-controls'
|
||||
export { TemplateModal } from './template-modal/template-modal'
|
||||
export { WebhookSettings } from './webhook-settings/webhook-settings'
|
||||
|
||||
@@ -1,756 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import {
|
||||
Award,
|
||||
BarChart3,
|
||||
Bell,
|
||||
BookOpen,
|
||||
Bot,
|
||||
Brain,
|
||||
Briefcase,
|
||||
Calculator,
|
||||
Cloud,
|
||||
Code,
|
||||
Cpu,
|
||||
CreditCard,
|
||||
Database,
|
||||
DollarSign,
|
||||
Edit,
|
||||
Eye,
|
||||
FileText,
|
||||
Folder,
|
||||
Globe,
|
||||
HeadphonesIcon,
|
||||
Layers,
|
||||
Lightbulb,
|
||||
LineChart,
|
||||
Loader2,
|
||||
Mail,
|
||||
Megaphone,
|
||||
MessageSquare,
|
||||
NotebookPen,
|
||||
Phone,
|
||||
Play,
|
||||
Search,
|
||||
Server,
|
||||
Settings,
|
||||
ShoppingCart,
|
||||
Star,
|
||||
Target,
|
||||
TrendingUp,
|
||||
User,
|
||||
Users,
|
||||
Workflow,
|
||||
Wrench,
|
||||
X,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ColorPicker } from '@/components/ui/color-picker'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { buildWorkflowStateForTemplate } from '@/lib/workflows/state-builder'
|
||||
|
||||
const logger = createLogger('TemplateModal')
|
||||
|
||||
const templateSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required').max(100, 'Name must be less than 100 characters'),
|
||||
description: z
|
||||
.string()
|
||||
.min(1, 'Description is required')
|
||||
.max(500, 'Description must be less than 500 characters'),
|
||||
author: z
|
||||
.string()
|
||||
.min(1, 'Author is required')
|
||||
.max(100, 'Author must be less than 100 characters'),
|
||||
authorType: z.enum(['user', 'organization']).default('user'),
|
||||
organizationId: z.string().optional(),
|
||||
icon: z.string().min(1, 'Icon is required'),
|
||||
color: z.string().regex(/^#[0-9A-F]{6}$/i, 'Color must be a valid hex color (e.g., #3972F6)'),
|
||||
})
|
||||
|
||||
type TemplateFormData = z.infer<typeof templateSchema>
|
||||
|
||||
interface Organization {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface TemplateModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
workflowId: string
|
||||
}
|
||||
|
||||
const icons = [
|
||||
// Content & Documentation
|
||||
{ value: 'FileText', label: 'File Text', component: FileText },
|
||||
{ value: 'NotebookPen', label: 'Notebook', component: NotebookPen },
|
||||
{ value: 'BookOpen', label: 'Book', component: BookOpen },
|
||||
{ value: 'Edit', label: 'Edit', component: Edit },
|
||||
|
||||
// Analytics & Charts
|
||||
{ value: 'BarChart3', label: 'Bar Chart', component: BarChart3 },
|
||||
{ value: 'LineChart', label: 'Line Chart', component: LineChart },
|
||||
{ value: 'TrendingUp', label: 'Trending Up', component: TrendingUp },
|
||||
{ value: 'Target', label: 'Target', component: Target },
|
||||
|
||||
// Database & Storage
|
||||
{ value: 'Database', label: 'Database', component: Database },
|
||||
{ value: 'Server', label: 'Server', component: Server },
|
||||
{ value: 'Cloud', label: 'Cloud', component: Cloud },
|
||||
{ value: 'Folder', label: 'Folder', component: Folder },
|
||||
|
||||
// Marketing & Communication
|
||||
{ value: 'Megaphone', label: 'Megaphone', component: Megaphone },
|
||||
{ value: 'Mail', label: 'Mail', component: Mail },
|
||||
{ value: 'MessageSquare', label: 'Message', component: MessageSquare },
|
||||
{ value: 'Phone', label: 'Phone', component: Phone },
|
||||
{ value: 'Bell', label: 'Bell', component: Bell },
|
||||
|
||||
// Sales & Finance
|
||||
{ value: 'DollarSign', label: 'Dollar Sign', component: DollarSign },
|
||||
{ value: 'CreditCard', label: 'Credit Card', component: CreditCard },
|
||||
{ value: 'Calculator', label: 'Calculator', component: Calculator },
|
||||
{ value: 'ShoppingCart', label: 'Shopping Cart', component: ShoppingCart },
|
||||
{ value: 'Briefcase', label: 'Briefcase', component: Briefcase },
|
||||
|
||||
// Support & Service
|
||||
{ value: 'HeadphonesIcon', label: 'Headphones', component: HeadphonesIcon },
|
||||
{ value: 'User', label: 'User', component: User },
|
||||
{ value: 'Users', label: 'Users', component: Users },
|
||||
{ value: 'Settings', label: 'Settings', component: Settings },
|
||||
{ value: 'Wrench', label: 'Wrench', component: Wrench },
|
||||
|
||||
// AI & Technology
|
||||
{ value: 'Bot', label: 'Bot', component: Bot },
|
||||
{ value: 'Brain', label: 'Brain', component: Brain },
|
||||
{ value: 'Cpu', label: 'CPU', component: Cpu },
|
||||
{ value: 'Code', label: 'Code', component: Code },
|
||||
{ value: 'Zap', label: 'Zap', component: Zap },
|
||||
|
||||
// Workflow & Process
|
||||
{ value: 'Workflow', label: 'Workflow', component: Workflow },
|
||||
{ value: 'Search', label: 'Search', component: Search },
|
||||
{ value: 'Play', label: 'Play', component: Play },
|
||||
{ value: 'Layers', label: 'Layers', component: Layers },
|
||||
|
||||
// General
|
||||
{ value: 'Lightbulb', label: 'Lightbulb', component: Lightbulb },
|
||||
{ value: 'Star', label: 'Star', component: Star },
|
||||
{ value: 'Globe', label: 'Globe', component: Globe },
|
||||
{ value: 'Award', label: 'Award', component: Award },
|
||||
]
|
||||
|
||||
export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalProps) {
|
||||
const { data: session } = useSession()
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [iconPopoverOpen, setIconPopoverOpen] = useState(false)
|
||||
const [existingTemplate, setExistingTemplate] = useState<any>(null)
|
||||
const [isLoadingTemplate, setIsLoadingTemplate] = useState(false)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [organizations, setOrganizations] = useState<Organization[]>([])
|
||||
const [loadingOrgs, setLoadingOrgs] = useState(false)
|
||||
|
||||
const form = useForm<TemplateFormData>({
|
||||
resolver: zodResolver(templateSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
description: '',
|
||||
author: session?.user?.name || session?.user?.email || '',
|
||||
authorType: 'user',
|
||||
organizationId: undefined,
|
||||
icon: 'FileText',
|
||||
color: '#3972F6',
|
||||
},
|
||||
})
|
||||
|
||||
// Watch form state to determine if all required fields are valid
|
||||
const formValues = form.watch()
|
||||
const authorType = form.watch('authorType')
|
||||
const isFormValid =
|
||||
form.formState.isValid &&
|
||||
formValues.name?.trim() &&
|
||||
formValues.description?.trim() &&
|
||||
formValues.author?.trim()
|
||||
|
||||
// Fetch user's organizations when modal opens
|
||||
useEffect(() => {
|
||||
const fetchOrganizations = async () => {
|
||||
if (!open || !session?.user?.id) return
|
||||
|
||||
setLoadingOrgs(true)
|
||||
try {
|
||||
const response = await fetch('/api/organizations')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setOrganizations(data.organizations || [])
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching organizations:', error)
|
||||
setOrganizations([])
|
||||
} finally {
|
||||
setLoadingOrgs(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (open) {
|
||||
fetchOrganizations()
|
||||
}
|
||||
}, [open, session?.user?.id])
|
||||
|
||||
// Check for existing template when modal opens
|
||||
useEffect(() => {
|
||||
if (open && workflowId) {
|
||||
checkExistingTemplate()
|
||||
}
|
||||
}, [open, workflowId])
|
||||
|
||||
const checkExistingTemplate = async () => {
|
||||
setIsLoadingTemplate(true)
|
||||
try {
|
||||
const response = await fetch(`/api/templates?workflowId=${workflowId}&limit=1`)
|
||||
if (response.ok) {
|
||||
const result = await response.json()
|
||||
const template = result.data?.[0] || null
|
||||
setExistingTemplate(template)
|
||||
|
||||
// Pre-fill form with existing template data
|
||||
if (template) {
|
||||
form.reset({
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
author: template.author,
|
||||
authorType: template.authorType || 'user',
|
||||
organizationId: template.organizationId || undefined,
|
||||
icon: template.icon,
|
||||
color: template.color,
|
||||
})
|
||||
} else {
|
||||
// No existing template found
|
||||
setExistingTemplate(null)
|
||||
// Reset form to defaults
|
||||
form.reset({
|
||||
name: '',
|
||||
description: '',
|
||||
author: session?.user?.name || session?.user?.email || '',
|
||||
authorType: 'user',
|
||||
organizationId: undefined,
|
||||
icon: 'FileText',
|
||||
color: '#3972F6',
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error checking existing template:', error)
|
||||
setExistingTemplate(null)
|
||||
} finally {
|
||||
setIsLoadingTemplate(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onSubmit = async (data: TemplateFormData) => {
|
||||
if (!session?.user) {
|
||||
logger.error('User not authenticated')
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
// Create the template state from current workflow using the same format as deployment
|
||||
const templateState = buildWorkflowStateForTemplate(workflowId)
|
||||
|
||||
const templateData = {
|
||||
workflowId,
|
||||
name: data.name,
|
||||
description: data.description || '',
|
||||
author: data.author,
|
||||
authorType: data.authorType,
|
||||
organizationId: data.organizationId,
|
||||
icon: data.icon,
|
||||
color: data.color,
|
||||
state: templateState,
|
||||
}
|
||||
|
||||
let response
|
||||
if (existingTemplate) {
|
||||
// Update existing template
|
||||
response = await fetch(`/api/templates/${existingTemplate.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(templateData),
|
||||
})
|
||||
} else {
|
||||
// Create new template
|
||||
response = await fetch('/api/templates', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(templateData),
|
||||
})
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(
|
||||
errorData.error || `Failed to ${existingTemplate ? 'update' : 'create'} template`
|
||||
)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
logger.info(`Template ${existingTemplate ? 'updated' : 'created'} successfully:`, result)
|
||||
|
||||
// Reset form and close modal
|
||||
form.reset()
|
||||
onOpenChange(false)
|
||||
|
||||
// TODO: Show success toast/notification
|
||||
} catch (error) {
|
||||
logger.error('Failed to create template:', error)
|
||||
// TODO: Show error toast/notification
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const SelectedIconComponent =
|
||||
icons.find((icon) => icon.value === form.watch('icon'))?.component || FileText
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className='flex h-[70vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-[600px]'
|
||||
hideCloseButton
|
||||
>
|
||||
<DialogHeader className='flex-shrink-0 border-b px-6 py-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<DialogTitle className='font-medium text-lg'>
|
||||
{isLoadingTemplate
|
||||
? 'Loading...'
|
||||
: existingTemplate
|
||||
? 'Update Template'
|
||||
: 'Publish Template'}
|
||||
</DialogTitle>
|
||||
{existingTemplate && (
|
||||
<div className='flex items-center gap-2'>
|
||||
{existingTemplate.stars > 0 && (
|
||||
<div className='flex items-center gap-1 rounded-full bg-yellow-50 px-2 py-1 dark:bg-yellow-900/20'>
|
||||
<Star className='h-3 w-3 fill-yellow-400 text-yellow-400' />
|
||||
<span className='font-medium text-xs text-yellow-700 dark:text-yellow-300'>
|
||||
{existingTemplate.stars}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{existingTemplate.views > 0 && (
|
||||
<div className='flex items-center gap-1 rounded-full bg-blue-50 px-2 py-1 dark:bg-blue-900/20'>
|
||||
<Eye className='h-3 w-3 text-blue-500' />
|
||||
<span className='font-medium text-blue-700 text-xs dark:text-blue-300'>
|
||||
{existingTemplate.views}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className={cn(
|
||||
'h-8 w-8 rounded-md p-0 text-muted-foreground/70 transition-all duration-200',
|
||||
'hover:scale-105 hover:bg-muted/50 hover:text-foreground',
|
||||
'active:scale-95',
|
||||
'focus-visible:ring-2 focus-visible:ring-muted-foreground/20 focus-visible:ring-offset-1'
|
||||
)}
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<X className='h-4 w-4' />
|
||||
<span className='sr-only'>Close</span>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className='flex flex-1 flex-col overflow-hidden'
|
||||
>
|
||||
<div className='flex-1 overflow-y-auto px-6 py-4'>
|
||||
{isLoadingTemplate ? (
|
||||
<div className='space-y-6'>
|
||||
{/* Icon and Color row */}
|
||||
<div className='flex gap-3'>
|
||||
<div className='w-20'>
|
||||
<Skeleton className='mb-2 h-4 w-8' /> {/* Label */}
|
||||
<Skeleton className='h-10 w-20' /> {/* Icon picker */}
|
||||
</div>
|
||||
<div className='w-20'>
|
||||
<Skeleton className='mb-2 h-4 w-10' /> {/* Label */}
|
||||
<Skeleton className='h-10 w-20' /> {/* Color picker */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Name field */}
|
||||
<div>
|
||||
<Skeleton className='mb-2 h-4 w-12' /> {/* Label */}
|
||||
<Skeleton className='h-10 w-full' /> {/* Input */}
|
||||
</div>
|
||||
|
||||
{/* Author and Author Type row */}
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<div>
|
||||
<Skeleton className='mb-2 h-4 w-14' /> {/* Label */}
|
||||
<Skeleton className='h-10 w-full' /> {/* Input */}
|
||||
</div>
|
||||
<div>
|
||||
<Skeleton className='mb-2 h-4 w-24' /> {/* Label */}
|
||||
<Skeleton className='h-10 w-full' /> {/* Select */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description field */}
|
||||
<div>
|
||||
<Skeleton className='mb-2 h-4 w-20' /> {/* Label */}
|
||||
<Skeleton className='h-20 w-full' /> {/* Textarea */}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-5'>
|
||||
<div className='flex gap-3'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='icon'
|
||||
render={({ field }) => (
|
||||
<FormItem className='w-20'>
|
||||
<FormLabel className='!text-foreground font-medium text-sm'>
|
||||
Icon
|
||||
</FormLabel>
|
||||
<Popover open={iconPopoverOpen} onOpenChange={setIconPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
role='combobox'
|
||||
className='h-10 w-20 rounded-[8px] border-border/50 p-0 transition-all duration-200 hover:border-border hover:bg-muted/50'
|
||||
>
|
||||
<SelectedIconComponent className='h-4 w-4' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='z-50 w-84 rounded-[8px] p-0' align='start'>
|
||||
<div className='p-3'>
|
||||
<div className='grid max-h-80 grid-cols-8 gap-2 overflow-y-auto'>
|
||||
{icons.map((icon) => {
|
||||
const IconComponent = icon.component
|
||||
return (
|
||||
<button
|
||||
key={icon.value}
|
||||
type='button'
|
||||
onClick={() => {
|
||||
field.onChange(icon.value)
|
||||
setIconPopoverOpen(false)
|
||||
}}
|
||||
className={cn(
|
||||
'flex h-8 w-8 items-center justify-center rounded-md border border-border/40 transition-all duration-200',
|
||||
'hover:scale-105 hover:border-border hover:bg-muted/50 active:scale-95',
|
||||
field.value === icon.value &&
|
||||
'border-primary/30 bg-primary/10 text-primary'
|
||||
)}
|
||||
>
|
||||
<IconComponent className='h-4 w-4' />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='color'
|
||||
render={({ field }) => (
|
||||
<FormItem className='w-20'>
|
||||
<FormLabel className='!text-foreground font-medium text-sm'>
|
||||
Color
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<ColorPicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
className='h-10 w-20 rounded-[8px]'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='!text-foreground font-medium text-sm'>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='Enter template name'
|
||||
className='h-10 rounded-[8px]'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='author'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='!text-foreground font-medium text-sm'>
|
||||
Author
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='Enter author name'
|
||||
className='h-10 rounded-[8px]'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='authorType'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='!text-foreground font-medium text-sm'>
|
||||
Author Type
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value)
|
||||
// Reset org selection when switching to user
|
||||
if (value === 'user') {
|
||||
form.setValue('organizationId', undefined)
|
||||
}
|
||||
}}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className='h-10 rounded-[8px]'>
|
||||
<SelectValue placeholder='Select author type' />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value='user'>User</SelectItem>
|
||||
<SelectItem value='organization'>Organization</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Organization selector - only show when authorType is 'organization' */}
|
||||
{authorType === 'organization' && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='organizationId'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='!text-foreground font-medium text-sm'>
|
||||
Organization
|
||||
</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger className='h-10 rounded-[8px]'>
|
||||
<SelectValue placeholder='Select an organization' />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{loadingOrgs ? (
|
||||
<SelectItem value='loading' disabled>
|
||||
Loading organizations...
|
||||
</SelectItem>
|
||||
) : organizations.length === 0 ? (
|
||||
<SelectItem value='none' disabled>
|
||||
No organizations available
|
||||
</SelectItem>
|
||||
) : (
|
||||
organizations.map((org) => (
|
||||
<SelectItem key={org.id} value={org.id}>
|
||||
{org.name}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='description'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='!text-foreground font-medium text-sm'>
|
||||
Description
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder='Describe what this template does...'
|
||||
className='min-h-[80px] resize-none rounded-[8px]'
|
||||
rows={3}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fixed Footer */}
|
||||
<div className='mt-auto border-t px-6 py-4'>
|
||||
<div className='flex items-center'>
|
||||
{existingTemplate && (
|
||||
<Button
|
||||
type='button'
|
||||
variant='destructive'
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
disabled={isSubmitting || isLoadingTemplate}
|
||||
className='h-9 rounded-[8px] px-4'
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type='submit'
|
||||
disabled={isSubmitting || !isFormValid || isLoadingTemplate}
|
||||
className={cn(
|
||||
'ml-auto h-9 rounded-[8px] px-4 font-[480]',
|
||||
'bg-[var(--brand-primary-hex)] hover:bg-[var(--brand-primary-hover-hex)]',
|
||||
'shadow-[0_0_0_0_var(--brand-primary-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]',
|
||||
'text-white transition-all duration-200',
|
||||
'disabled:opacity-50 disabled:hover:bg-[var(--brand-primary-hex)] disabled:hover:shadow-none'
|
||||
)}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
{existingTemplate ? 'Updating...' : 'Publishing...'}
|
||||
</>
|
||||
) : existingTemplate ? (
|
||||
'Update Template'
|
||||
) : (
|
||||
'Publish Template'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
{existingTemplate && (
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Template?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Deleting this template will remove it from the gallery. This action cannot be
|
||||
undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className='flex'>
|
||||
<AlertDialogCancel className='h-9 w-full rounded-[8px]' disabled={isDeleting}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className='h-9 w-full rounded-[8px] bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600'
|
||||
disabled={isDeleting}
|
||||
onClick={async () => {
|
||||
if (!existingTemplate) return
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
const resp = await fetch(`/api/templates/${existingTemplate.id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}))
|
||||
throw new Error(err.error || 'Failed to delete template')
|
||||
}
|
||||
setShowDeleteDialog(false)
|
||||
onOpenChange(false)
|
||||
} catch (err) {
|
||||
logger.error('Failed to delete template', err)
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,16 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Tooltip } from '@/components/emcn'
|
||||
import {
|
||||
type SlackChannelInfo,
|
||||
SlackChannelSelector,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/channel-selector/components/slack-channel-selector'
|
||||
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
|
||||
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-depends-on-gate'
|
||||
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-foreign-credential'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import type { SelectorContext } from '@/hooks/selectors/types'
|
||||
|
||||
interface ChannelSelectorInputProps {
|
||||
blockId: string
|
||||
@@ -41,14 +39,12 @@ export function ChannelSelectorInput({
|
||||
const effectiveAuthMethod = previewContextValues?.authMethod ?? authMethod
|
||||
const effectiveBotToken = previewContextValues?.botToken ?? botToken
|
||||
const effectiveCredential = previewContextValues?.credential ?? connectedCredential
|
||||
const [selectedChannelId, setSelectedChannelId] = useState<string>('')
|
||||
const [_channelInfo, setChannelInfo] = useState<SlackChannelInfo | null>(null)
|
||||
const [_channelInfo, setChannelInfo] = useState<string | null>(null)
|
||||
|
||||
// Get provider-specific values
|
||||
const provider = subBlock.provider || 'slack'
|
||||
const isSlack = provider === 'slack'
|
||||
// Central dependsOn gating
|
||||
const { finalDisabled, dependsOn, dependencyValues } = useDependsOnGate(blockId, subBlock, {
|
||||
const { finalDisabled, dependsOn } = useDependsOnGate(blockId, subBlock, {
|
||||
disabled,
|
||||
isPreview,
|
||||
previewContextValues,
|
||||
@@ -69,70 +65,60 @@ export function ChannelSelectorInput({
|
||||
// Get the current value from the store or prop value if in preview mode (same pattern as file-selector)
|
||||
useEffect(() => {
|
||||
const val = isPreview && previewValue !== undefined ? previewValue : storeValue
|
||||
if (val && typeof val === 'string') {
|
||||
setSelectedChannelId(val)
|
||||
if (typeof val === 'string') {
|
||||
setChannelInfo(val)
|
||||
}
|
||||
}, [isPreview, previewValue, storeValue])
|
||||
|
||||
// Clear channel when any declared dependency changes (e.g., authMethod/credential)
|
||||
const prevDepsSigRef = useRef<string>('')
|
||||
useEffect(() => {
|
||||
if (dependsOn.length === 0) return
|
||||
const currentSig = JSON.stringify(dependencyValues)
|
||||
if (prevDepsSigRef.current && prevDepsSigRef.current !== currentSig) {
|
||||
if (!isPreview) {
|
||||
setSelectedChannelId('')
|
||||
setChannelInfo(null)
|
||||
setStoreValue('')
|
||||
}
|
||||
}
|
||||
prevDepsSigRef.current = currentSig
|
||||
}, [dependsOn, dependencyValues, isPreview, setStoreValue])
|
||||
const requiresCredential = dependsOn.includes('credential')
|
||||
const missingCredential = !credential || credential.trim().length === 0
|
||||
const shouldForceDisable = requiresCredential && (missingCredential || isForeignCredential)
|
||||
|
||||
// Handle channel selection (same pattern as file-selector)
|
||||
const handleChannelChange = (channelId: string, info?: SlackChannelInfo) => {
|
||||
setSelectedChannelId(channelId)
|
||||
setChannelInfo(info || null)
|
||||
if (!isPreview) {
|
||||
setStoreValue(channelId)
|
||||
}
|
||||
onChannelSelect?.(channelId)
|
||||
}
|
||||
const context: SelectorContext = useMemo(
|
||||
() => ({
|
||||
credentialId: credential,
|
||||
workflowId: workflowIdFromUrl,
|
||||
}),
|
||||
[credential, workflowIdFromUrl]
|
||||
)
|
||||
|
||||
// Render Slack channel selector
|
||||
if (isSlack) {
|
||||
if (!isSlack) {
|
||||
return (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div className='w-full'>
|
||||
<SlackChannelSelector
|
||||
value={selectedChannelId}
|
||||
onChange={(channelId: string, channelInfo?: SlackChannelInfo) => {
|
||||
handleChannelChange(channelId, channelInfo)
|
||||
}}
|
||||
credential={credential}
|
||||
label={subBlock.placeholder || 'Select Slack channel'}
|
||||
disabled={finalDisabled}
|
||||
workflowId={workflowIdFromUrl}
|
||||
isForeignCredential={isForeignCredential}
|
||||
/>
|
||||
<div className='w-full rounded border border-dashed p-4 text-center text-muted-foreground text-sm'>
|
||||
Channel selector not supported for provider: {provider}
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
<p>This channel selector is not yet implemented for {provider}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)
|
||||
}
|
||||
|
||||
// Default fallback for unsupported providers
|
||||
return (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div className='w-full rounded border border-dashed p-4 text-center text-muted-foreground text-sm'>
|
||||
Channel selector not supported for provider: {provider}
|
||||
<div className='w-full'>
|
||||
<SelectorCombobox
|
||||
blockId={blockId}
|
||||
subBlock={subBlock}
|
||||
selectorKey='slack.channels'
|
||||
selectorContext={context}
|
||||
disabled={finalDisabled || shouldForceDisable || isForeignCredential}
|
||||
isPreview={isPreview}
|
||||
previewValue={previewValue ?? null}
|
||||
placeholder={subBlock.placeholder || 'Select Slack channel'}
|
||||
onOptionChange={(value) => {
|
||||
setChannelInfo(value)
|
||||
if (!isPreview) {
|
||||
onChannelSelect?.(value)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
<p>This channel selector is not yet implemented for {provider}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { Check, ChevronDown, Hash, Lock, RefreshCw } from 'lucide-react'
|
||||
import { SlackIcon } from '@/components/icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { useDisplayNamesStore } from '@/stores/display-names/store'
|
||||
|
||||
export interface SlackChannelInfo {
|
||||
id: string
|
||||
name: string
|
||||
isPrivate: boolean
|
||||
}
|
||||
|
||||
interface SlackChannelSelectorProps {
|
||||
value: string
|
||||
onChange: (channelId: string, channelInfo?: SlackChannelInfo) => void
|
||||
credential: string
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
workflowId?: string
|
||||
isForeignCredential?: boolean
|
||||
}
|
||||
|
||||
export function SlackChannelSelector({
|
||||
value,
|
||||
onChange,
|
||||
credential,
|
||||
label = 'Select Slack channel',
|
||||
disabled = false,
|
||||
workflowId,
|
||||
isForeignCredential = false,
|
||||
}: SlackChannelSelectorProps) {
|
||||
const [channels, setChannels] = useState<SlackChannelInfo[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [initialFetchDone, setInitialFetchDone] = useState(false)
|
||||
|
||||
// Get cached display name
|
||||
const cachedChannelName = useDisplayNamesStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (!credential || !value) return null
|
||||
return state.cache.channels[credential]?.[value] || null
|
||||
},
|
||||
[credential, value]
|
||||
)
|
||||
)
|
||||
|
||||
// Fetch channels from Slack API
|
||||
const fetchChannels = useCallback(async () => {
|
||||
if (!credential) return
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/tools/slack/channels', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ credential, workflowId }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const errorData = await res
|
||||
.json()
|
||||
.catch(() => ({ error: `HTTP error! status: ${res.status}` }))
|
||||
setError(errorData.error || `HTTP error! status: ${res.status}`)
|
||||
setChannels([])
|
||||
setInitialFetchDone(true)
|
||||
return
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
if (data.error) {
|
||||
setError(data.error)
|
||||
setChannels([])
|
||||
setInitialFetchDone(true)
|
||||
} else {
|
||||
setChannels(data.channels)
|
||||
setInitialFetchDone(true)
|
||||
|
||||
// Cache channel names in display names store
|
||||
if (credential) {
|
||||
const channelMap = data.channels.reduce(
|
||||
(acc: Record<string, string>, ch: SlackChannelInfo) => {
|
||||
acc[ch.id] = `#${ch.name}`
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
useDisplayNamesStore.getState().setDisplayNames('channels', credential, channelMap)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as Error).name === 'AbortError') return
|
||||
setError((err as Error).message)
|
||||
setChannels([])
|
||||
setInitialFetchDone(true)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [credential])
|
||||
|
||||
// Handle dropdown open/close - fetch channels when opening
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
setOpen(isOpen)
|
||||
|
||||
// Only fetch channels when opening the dropdown and if we have valid credential
|
||||
if (isOpen && credential && (!initialFetchDone || channels.length === 0)) {
|
||||
fetchChannels()
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectChannel = (channel: SlackChannelInfo) => {
|
||||
onChange(channel.id, channel)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const getChannelIcon = (channel: SlackChannelInfo) => {
|
||||
return channel.isPrivate ? <Lock className='h-1.5 w-1.5' /> : <Hash className='h-1.5 w-1.5' />
|
||||
}
|
||||
|
||||
const formatChannelName = (channel: SlackChannelInfo) => {
|
||||
return channel.name
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
role='combobox'
|
||||
aria-expanded={open}
|
||||
className='relative w-full justify-between'
|
||||
disabled={disabled || !credential}
|
||||
title={isForeignCredential ? 'Using a shared account' : undefined}
|
||||
>
|
||||
<div className='flex max-w-[calc(100%-20px)] items-center gap-2 overflow-hidden'>
|
||||
<SlackIcon className='h-4 w-4 text-[#611f69]' />
|
||||
{cachedChannelName ? (
|
||||
<span className='truncate font-normal'>{cachedChannelName}</span>
|
||||
) : (
|
||||
<span className='truncate text-muted-foreground'>{label}</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown className='absolute right-3 h-4 w-4 shrink-0 opacity-50' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-[250px] p-0' align='start'>
|
||||
<Command>
|
||||
<CommandInput placeholder='Search channels...' />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{loading ? (
|
||||
<div className='flex items-center justify-center p-4'>
|
||||
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||
<span className='ml-2'>Loading channels...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='text-destructive text-sm'>{error}</p>
|
||||
</div>
|
||||
) : !credential ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>Missing credentials</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Please configure Slack credentials.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No channels found</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
No channels available for this Slack workspace.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CommandEmpty>
|
||||
|
||||
{channels.length > 0 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
Channels
|
||||
</div>
|
||||
{channels.map((channel) => (
|
||||
<CommandItem
|
||||
key={channel.id}
|
||||
value={`channel-${channel.id}-${channel.name}`}
|
||||
onSelect={() => handleSelectChannel(channel)}
|
||||
className='cursor-pointer'
|
||||
>
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
<SlackIcon className='h-4 w-4 text-[#611f69]' />
|
||||
{getChannelIcon(channel)}
|
||||
<span className='truncate font-normal'>{formatChannelName(channel)}</span>
|
||||
{channel.isPrivate && (
|
||||
<span className='ml-auto text-muted-foreground text-xs'>Private</span>
|
||||
)}
|
||||
</div>
|
||||
{channel.id === value && <Check className='ml-auto h-4 w-4' />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -1,20 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Check, ChevronDown, ExternalLink, RefreshCw } from 'lucide-react'
|
||||
import { Button } from '@/components/emcn/components/button/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import { Button, Combobox } from '@/components/emcn/components'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
type Credential,
|
||||
getCanonicalScopesForProvider,
|
||||
getProviderIdFromServiceId,
|
||||
getServiceIdFromScopes,
|
||||
@@ -25,9 +15,8 @@ import {
|
||||
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useOAuthCredentialDetail, useOAuthCredentials } from '@/hooks/queries/oauth-credentials'
|
||||
import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status'
|
||||
import { useDisplayNamesStore } from '@/stores/display-names/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
const logger = createLogger('CredentialSelector')
|
||||
@@ -47,262 +36,133 @@ export function CredentialSelector({
|
||||
isPreview = false,
|
||||
previewValue,
|
||||
}: CredentialSelectorProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [credentials, setCredentials] = useState<Credential[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [showOAuthModal, setShowOAuthModal] = useState(false)
|
||||
const [selectedId, setSelectedId] = useState('')
|
||||
const [hasForeignMeta, setHasForeignMeta] = useState(false)
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
|
||||
const [storeValue, setStoreValue] = useSubBlockValue<string | null>(blockId, subBlock.id)
|
||||
|
||||
// Use collaborative state management via useSubBlockValue hook
|
||||
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
|
||||
|
||||
// Extract values from subBlock config
|
||||
const provider = subBlock.provider as OAuthProvider
|
||||
const requiredScopes = subBlock.requiredScopes || []
|
||||
const label = subBlock.placeholder || 'Select credential'
|
||||
const serviceId = subBlock.serviceId
|
||||
|
||||
// Get the effective value (preview or store value)
|
||||
const effectiveValue = isPreview && previewValue !== undefined ? previewValue : storeValue
|
||||
const selectedId = typeof effectiveValue === 'string' ? effectiveValue : ''
|
||||
|
||||
const effectiveServiceId = useMemo(
|
||||
() => serviceId || getServiceIdFromScopes(provider, requiredScopes),
|
||||
[provider, requiredScopes, serviceId]
|
||||
)
|
||||
|
||||
const effectiveProviderId = useMemo(
|
||||
() => getProviderIdFromServiceId(effectiveServiceId),
|
||||
[effectiveServiceId]
|
||||
)
|
||||
|
||||
const {
|
||||
data: credentials = [],
|
||||
isFetching: credentialsLoading,
|
||||
refetch: refetchCredentials,
|
||||
} = useOAuthCredentials(effectiveProviderId, Boolean(effectiveProviderId))
|
||||
|
||||
const selectedCredential = useMemo(
|
||||
() => credentials.find((cred) => cred.id === selectedId),
|
||||
[credentials, selectedId]
|
||||
)
|
||||
|
||||
const shouldFetchForeignMeta =
|
||||
Boolean(selectedId) &&
|
||||
!selectedCredential &&
|
||||
Boolean(activeWorkflowId) &&
|
||||
Boolean(effectiveProviderId)
|
||||
|
||||
const { data: foreignCredentials = [], isFetching: foreignMetaLoading } =
|
||||
useOAuthCredentialDetail(
|
||||
shouldFetchForeignMeta ? selectedId : undefined,
|
||||
activeWorkflowId || undefined,
|
||||
shouldFetchForeignMeta
|
||||
)
|
||||
|
||||
const hasForeignMeta = foreignCredentials.length > 0
|
||||
const isForeign = Boolean(selectedId && !selectedCredential && hasForeignMeta)
|
||||
|
||||
const resolvedLabel = useMemo(() => {
|
||||
if (selectedCredential) return selectedCredential.name
|
||||
if (isForeign) return 'Saved by collaborator'
|
||||
return ''
|
||||
}, [selectedCredential, isForeign])
|
||||
|
||||
// Initialize selectedId with the effective value
|
||||
useEffect(() => {
|
||||
setSelectedId(effectiveValue || '')
|
||||
}, [effectiveValue])
|
||||
if (!isEditing) {
|
||||
setInputValue(resolvedLabel)
|
||||
}
|
||||
}, [resolvedLabel, isEditing])
|
||||
|
||||
// Derive service and provider IDs using useMemo
|
||||
const effectiveServiceId = useMemo(() => {
|
||||
return serviceId || getServiceIdFromScopes(provider, requiredScopes)
|
||||
}, [provider, requiredScopes, serviceId])
|
||||
const invalidSelection =
|
||||
!isPreview &&
|
||||
Boolean(selectedId) &&
|
||||
!selectedCredential &&
|
||||
!hasForeignMeta &&
|
||||
!credentialsLoading &&
|
||||
!foreignMetaLoading
|
||||
|
||||
const effectiveProviderId = useMemo(() => {
|
||||
return getProviderIdFromServiceId(effectiveServiceId)
|
||||
}, [effectiveServiceId])
|
||||
useEffect(() => {
|
||||
if (!invalidSelection) return
|
||||
logger.info('Clearing invalid credential selection - credential was disconnected', {
|
||||
selectedId,
|
||||
provider: effectiveProviderId,
|
||||
})
|
||||
setStoreValue('')
|
||||
}, [invalidSelection, selectedId, effectiveProviderId, setStoreValue])
|
||||
|
||||
// Fetch available credentials for this provider
|
||||
const fetchCredentials = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const response = await fetch(`/api/auth/oauth/credentials?provider=${effectiveProviderId}`)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
const creds = data.credentials as Credential[]
|
||||
let foreignMetaFound = false
|
||||
useCredentialRefreshTriggers(refetchCredentials, effectiveProviderId, provider)
|
||||
|
||||
// If persisted selection is not among viewer's credentials, attempt to fetch its metadata
|
||||
if (
|
||||
selectedId &&
|
||||
!(creds || []).some((cred: Credential) => cred.id === selectedId) &&
|
||||
activeWorkflowId
|
||||
) {
|
||||
try {
|
||||
const metaResp = await fetch(
|
||||
`/api/auth/oauth/credentials?credentialId=${selectedId}&workflowId=${activeWorkflowId}`
|
||||
)
|
||||
if (metaResp.ok) {
|
||||
const meta = await metaResp.json()
|
||||
if (meta.credentials?.length) {
|
||||
// Mark as foreign, but do NOT merge into list to avoid leaking owner email
|
||||
foreignMetaFound = true
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore meta errors
|
||||
}
|
||||
}
|
||||
|
||||
setHasForeignMeta(foreignMetaFound)
|
||||
setCredentials(creds)
|
||||
|
||||
// Cache credential names in display names store
|
||||
if (effectiveProviderId) {
|
||||
const credentialMap = creds.reduce((acc: Record<string, string>, cred: Credential) => {
|
||||
acc[cred.id] = cred.name
|
||||
return acc
|
||||
}, {})
|
||||
useDisplayNamesStore
|
||||
.getState()
|
||||
.setDisplayNames('credentials', effectiveProviderId, credentialMap)
|
||||
}
|
||||
|
||||
// Check if the currently selected credential still exists
|
||||
const selectedCredentialStillExists = (creds || []).some(
|
||||
(cred: Credential) => cred.id === selectedId
|
||||
)
|
||||
const shouldClearPersistedSelection =
|
||||
!isPreview && selectedId && !selectedCredentialStillExists && !foreignMetaFound
|
||||
|
||||
if (shouldClearPersistedSelection) {
|
||||
logger.info('Clearing invalid credential selection - credential was disconnected', {
|
||||
selectedId,
|
||||
provider: effectiveProviderId,
|
||||
})
|
||||
|
||||
// Clear via setStoreValue to trigger cascade
|
||||
setStoreValue('')
|
||||
setSelectedId('')
|
||||
|
||||
if (effectiveProviderId) {
|
||||
useDisplayNamesStore
|
||||
.getState()
|
||||
.removeDisplayName('credentials', effectiveProviderId, selectedId)
|
||||
}
|
||||
}
|
||||
const handleOpenChange = useCallback(
|
||||
(isOpen: boolean) => {
|
||||
if (isOpen) {
|
||||
void refetchCredentials()
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching credentials:', { error })
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [effectiveProviderId, selectedId, activeWorkflowId, isPreview, setStoreValue])
|
||||
},
|
||||
[refetchCredentials]
|
||||
)
|
||||
|
||||
// Fetch credentials on initial mount and whenever the subblock value changes externally
|
||||
useEffect(() => {
|
||||
fetchCredentials()
|
||||
}, [fetchCredentials, effectiveValue])
|
||||
|
||||
// When the selectedId changes (e.g., collaborator saved a credential), determine if it's foreign
|
||||
useEffect(() => {
|
||||
let aborted = false
|
||||
;(async () => {
|
||||
try {
|
||||
if (!selectedId) {
|
||||
setHasForeignMeta(false)
|
||||
return
|
||||
}
|
||||
// If the selected credential exists in viewer's list, it's not foreign
|
||||
if ((credentials || []).some((cred) => cred.id === selectedId)) {
|
||||
setHasForeignMeta(false)
|
||||
return
|
||||
}
|
||||
if (!activeWorkflowId) return
|
||||
const metaResp = await fetch(
|
||||
`/api/auth/oauth/credentials?credentialId=${selectedId}&workflowId=${activeWorkflowId}`
|
||||
)
|
||||
if (aborted) return
|
||||
if (metaResp.ok) {
|
||||
const meta = await metaResp.json()
|
||||
setHasForeignMeta(!!meta.credentials?.length)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
aborted = true
|
||||
}
|
||||
}, [selectedId, credentials, activeWorkflowId])
|
||||
|
||||
// This effect is no longer needed since we're using effectiveValue directly
|
||||
|
||||
// Listen for visibility changes to update credentials when user returns from settings
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
fetchCredentials()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
}
|
||||
}, [fetchCredentials])
|
||||
|
||||
// Also handle BFCache restores (back/forward navigation) where visibility change may not fire reliably
|
||||
useEffect(() => {
|
||||
const handlePageShow = (event: any) => {
|
||||
if (event?.persisted) {
|
||||
fetchCredentials()
|
||||
}
|
||||
}
|
||||
window.addEventListener('pageshow', handlePageShow)
|
||||
return () => {
|
||||
window.removeEventListener('pageshow', handlePageShow)
|
||||
}
|
||||
}, [fetchCredentials])
|
||||
|
||||
// Listen for credential disconnection events from settings modal
|
||||
useEffect(() => {
|
||||
const handleCredentialDisconnected = (event: Event) => {
|
||||
const customEvent = event as CustomEvent
|
||||
const { providerId } = customEvent.detail
|
||||
// Re-fetch if this disconnection affects our provider
|
||||
if (providerId && (providerId === effectiveProviderId || providerId.startsWith(provider))) {
|
||||
fetchCredentials()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('credential-disconnected', handleCredentialDisconnected)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('credential-disconnected', handleCredentialDisconnected)
|
||||
}
|
||||
}, [fetchCredentials, effectiveProviderId, provider])
|
||||
|
||||
// Handle popover open to fetch fresh credentials
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
setOpen(isOpen)
|
||||
if (isOpen) {
|
||||
// Fetch fresh credentials when opening the dropdown
|
||||
fetchCredentials()
|
||||
}
|
||||
}
|
||||
|
||||
// Get the selected credential
|
||||
const selectedCredential = credentials.find((cred) => cred.id === selectedId)
|
||||
const isForeign = !!(selectedId && !selectedCredential && hasForeignMeta)
|
||||
|
||||
// If the list doesn’t contain the effective value but meta says it exists, synthesize a non-leaky placeholder to render stable UI
|
||||
const displayName = selectedCredential
|
||||
? selectedCredential.name
|
||||
: isForeign
|
||||
? 'Saved by collaborator'
|
||||
: undefined
|
||||
|
||||
// Determine if additional permissions are required for the selected credential
|
||||
const hasSelection = !!selectedCredential
|
||||
const hasSelection = Boolean(selectedCredential)
|
||||
const missingRequiredScopes = hasSelection
|
||||
? getMissingRequiredScopes(selectedCredential, requiredScopes || [])
|
||||
? getMissingRequiredScopes(selectedCredential!, requiredScopes || [])
|
||||
: []
|
||||
|
||||
const needsUpdate =
|
||||
hasSelection && missingRequiredScopes.length > 0 && !disabled && !isPreview && !isLoading
|
||||
hasSelection &&
|
||||
missingRequiredScopes.length > 0 &&
|
||||
!disabled &&
|
||||
!isPreview &&
|
||||
!credentialsLoading
|
||||
|
||||
// Handle selection
|
||||
const handleSelect = (credentialId: string) => {
|
||||
const previousId = selectedId || (effectiveValue as string) || ''
|
||||
setSelectedId(credentialId)
|
||||
if (!isPreview) {
|
||||
const handleSelect = useCallback(
|
||||
(credentialId: string) => {
|
||||
if (isPreview) return
|
||||
setStoreValue(credentialId)
|
||||
}
|
||||
setOpen(false)
|
||||
}
|
||||
setIsEditing(false)
|
||||
},
|
||||
[isPreview, setStoreValue]
|
||||
)
|
||||
|
||||
// Handle adding a new credential
|
||||
const handleAddCredential = () => {
|
||||
// Show the OAuth modal
|
||||
const handleAddCredential = useCallback(() => {
|
||||
setShowOAuthModal(true)
|
||||
setOpen(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Get provider icon
|
||||
const getProviderIcon = (providerName: OAuthProvider) => {
|
||||
const getProviderIcon = useCallback((providerName: OAuthProvider) => {
|
||||
const { baseProvider } = parseProvider(providerName)
|
||||
const baseProviderConfig = OAUTH_PROVIDERS[baseProvider]
|
||||
|
||||
if (!baseProviderConfig) {
|
||||
return <ExternalLink className='h-4 w-4' />
|
||||
return <ExternalLink className='h-3 w-3' />
|
||||
}
|
||||
// Always use the base provider icon for a more consistent UI
|
||||
return baseProviderConfig.icon({ className: 'h-4 w-4' })
|
||||
}
|
||||
return baseProviderConfig.icon({ className: 'h-3 w-3' })
|
||||
}, [])
|
||||
|
||||
// Get provider name
|
||||
const getProviderName = (providerName: OAuthProvider) => {
|
||||
const getProviderName = useCallback((providerName: OAuthProvider) => {
|
||||
const { baseProvider } = parseProvider(providerName)
|
||||
const baseProviderConfig = OAUTH_PROVIDERS[baseProvider]
|
||||
|
||||
@@ -310,88 +170,79 @@ export function CredentialSelector({
|
||||
return baseProviderConfig.name
|
||||
}
|
||||
|
||||
// Fallback: capitalize the provider name
|
||||
return providerName
|
||||
.split('-')
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const comboboxOptions = useMemo(() => {
|
||||
const options = credentials.map((cred) => ({
|
||||
label: cred.name,
|
||||
value: cred.id,
|
||||
}))
|
||||
|
||||
if (credentials.length === 0) {
|
||||
options.push({
|
||||
label: `Connect ${getProviderName(provider)} account`,
|
||||
value: '__connect_account__',
|
||||
})
|
||||
}
|
||||
|
||||
return options
|
||||
}, [credentials, provider, getProviderName])
|
||||
|
||||
const selectedCredentialProvider = selectedCredential?.provider ?? provider
|
||||
|
||||
const overlayContent = useMemo(() => {
|
||||
if (!inputValue) return null
|
||||
|
||||
return (
|
||||
<div className='flex w-full items-center truncate'>
|
||||
<div className='mr-2 flex-shrink-0 opacity-90'>
|
||||
{getProviderIcon(selectedCredentialProvider)}
|
||||
</div>
|
||||
<span className='truncate'>{inputValue}</span>
|
||||
</div>
|
||||
)
|
||||
}, [getProviderIcon, inputValue, selectedCredentialProvider])
|
||||
|
||||
const handleComboboxChange = useCallback(
|
||||
(value: string) => {
|
||||
if (value === '__connect_account__') {
|
||||
handleAddCredential()
|
||||
return
|
||||
}
|
||||
|
||||
const matchedCred = credentials.find((c) => c.id === value)
|
||||
if (matchedCred) {
|
||||
setInputValue(matchedCred.name)
|
||||
handleSelect(value)
|
||||
return
|
||||
}
|
||||
|
||||
setIsEditing(true)
|
||||
setInputValue(value)
|
||||
},
|
||||
[credentials, handleAddCredential, handleSelect]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
role='combobox'
|
||||
aria-expanded={open}
|
||||
className='relative w-full justify-between'
|
||||
disabled={disabled}
|
||||
>
|
||||
<div className='flex max-w-[calc(100%-20px)] items-center gap-2 overflow-hidden'>
|
||||
{getProviderIcon(provider)}
|
||||
<span
|
||||
className={displayName ? 'truncate font-normal' : 'truncate text-muted-foreground'}
|
||||
>
|
||||
{displayName || label}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown className='absolute right-3 h-4 w-4 shrink-0 opacity-50' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-[250px] p-0' align='start'>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder='Search credentials...'
|
||||
className='text-foreground placeholder:text-muted-foreground'
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{isLoading ? (
|
||||
<div className='flex items-center justify-center p-4'>
|
||||
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||
<span className='ml-2'>Loading credentials...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No credentials found.</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Connect a new account to continue.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CommandEmpty>
|
||||
{credentials.length > 0 && (
|
||||
<CommandGroup>
|
||||
{credentials.map((cred) => (
|
||||
<CommandItem
|
||||
key={cred.id}
|
||||
value={cred.id}
|
||||
onSelect={() => handleSelect(cred.id)}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
{getProviderIcon(cred.provider)}
|
||||
<span className='font-normal'>{cred.name}</span>
|
||||
</div>
|
||||
{cred.id === selectedId && <Check className='ml-auto h-4 w-4' />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{credentials.length === 0 && (
|
||||
<CommandGroup>
|
||||
<CommandItem onSelect={handleAddCredential}>
|
||||
<div className='flex items-center gap-2 text-foreground'>
|
||||
{getProviderIcon(provider)}
|
||||
<span>Connect {getProviderName(provider)} account</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Combobox
|
||||
options={comboboxOptions}
|
||||
value={inputValue}
|
||||
selectedValue={selectedId}
|
||||
onChange={handleComboboxChange}
|
||||
onOpenChange={handleOpenChange}
|
||||
placeholder={label}
|
||||
disabled={disabled}
|
||||
editable={true}
|
||||
filterOptions={true}
|
||||
isLoading={credentialsLoading}
|
||||
overlayContent={overlayContent}
|
||||
className={selectedId ? 'pl-[28px]' : ''}
|
||||
/>
|
||||
|
||||
{needsUpdate && (
|
||||
<div className='mt-2 flex items-center justify-between rounded-[6px] border border-amber-300/40 bg-amber-50/60 px-2 py-1 font-medium text-[12px] transition-colors dark:bg-amber-950/10'>
|
||||
@@ -414,3 +265,49 @@ export function CredentialSelector({
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function useCredentialRefreshTriggers(
|
||||
refetchCredentials: () => Promise<unknown>,
|
||||
effectiveProviderId?: string,
|
||||
provider?: OAuthProvider
|
||||
) {
|
||||
useEffect(() => {
|
||||
const refresh = () => {
|
||||
void refetchCredentials()
|
||||
}
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
const handlePageShow = (event: Event) => {
|
||||
if ('persisted' in event && (event as PageTransitionEvent).persisted) {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
const handleCredentialDisconnected = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<{ providerId?: string }>
|
||||
const providerId = customEvent.detail?.providerId
|
||||
|
||||
if (
|
||||
providerId &&
|
||||
(providerId === effectiveProviderId || (provider && providerId.startsWith(provider)))
|
||||
) {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
window.addEventListener('pageshow', handlePageShow)
|
||||
window.addEventListener('credential-disconnected', handleCredentialDisconnected)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
window.removeEventListener('pageshow', handlePageShow)
|
||||
window.removeEventListener('credential-disconnected', handleCredentialDisconnected)
|
||||
}
|
||||
}, [refetchCredentials, effectiveProviderId, provider])
|
||||
}
|
||||
|
||||
@@ -1,23 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Check, ChevronDown, FileText, RefreshCw } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { Tooltip } from '@/components/emcn'
|
||||
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
|
||||
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-depends-on-gate'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { useKnowledgeBaseDocuments } from '@/hooks/use-knowledge'
|
||||
import { useDisplayNamesStore } from '@/stores/display-names/store'
|
||||
import type { DocumentData } from '@/stores/knowledge/store'
|
||||
import type { SelectorContext } from '@/hooks/selectors/types'
|
||||
|
||||
interface DocumentSelectorProps {
|
||||
blockId: string
|
||||
@@ -36,186 +25,54 @@ export function DocumentSelector({
|
||||
isPreview = false,
|
||||
previewValue,
|
||||
}: DocumentSelectorProps) {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
|
||||
const [knowledgeBaseId] = useSubBlockValue(blockId, 'knowledgeBaseId')
|
||||
const { finalDisabled } = useDependsOnGate(blockId, subBlock, { disabled, isPreview })
|
||||
const [knowledgeBaseIdValue] = useSubBlockValue(blockId, 'knowledgeBaseId')
|
||||
const normalizedKnowledgeBaseId =
|
||||
typeof knowledgeBaseId === 'string' && knowledgeBaseId.trim().length > 0
|
||||
? knowledgeBaseId
|
||||
typeof knowledgeBaseIdValue === 'string' && knowledgeBaseIdValue.trim().length > 0
|
||||
? knowledgeBaseIdValue
|
||||
: null
|
||||
|
||||
const value = isPreview ? previewValue : storeValue
|
||||
|
||||
const { finalDisabled } = useDependsOnGate(blockId, subBlock, { disabled, isPreview })
|
||||
const isDisabled = finalDisabled
|
||||
|
||||
const {
|
||||
documents,
|
||||
isLoading: documentsLoading,
|
||||
error: documentsError,
|
||||
refreshDocuments,
|
||||
} = useKnowledgeBaseDocuments(normalizedKnowledgeBaseId ?? '', {
|
||||
limit: 500,
|
||||
offset: 0,
|
||||
enabled: open && Boolean(normalizedKnowledgeBaseId),
|
||||
})
|
||||
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
if (isPreview || isDisabled) return
|
||||
|
||||
setOpen(isOpen)
|
||||
|
||||
if (isOpen && normalizedKnowledgeBaseId) {
|
||||
void refreshDocuments()
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectDocument = (document: DocumentData) => {
|
||||
if (isPreview) return
|
||||
|
||||
setStoreValue(document.id)
|
||||
onDocumentSelect?.(document.id)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!normalizedKnowledgeBaseId) {
|
||||
setError(null)
|
||||
}
|
||||
}, [normalizedKnowledgeBaseId])
|
||||
|
||||
useEffect(() => {
|
||||
setError(documentsError)
|
||||
}, [documentsError])
|
||||
|
||||
useEffect(() => {
|
||||
if (!normalizedKnowledgeBaseId || documents.length === 0) return
|
||||
|
||||
const documentMap = documents.reduce<Record<string, string>>((acc, doc) => {
|
||||
acc[doc.id] = doc.filename
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
useDisplayNamesStore
|
||||
.getState()
|
||||
.setDisplayNames('documents', normalizedKnowledgeBaseId as string, documentMap)
|
||||
}, [documents, normalizedKnowledgeBaseId])
|
||||
|
||||
const formatDocumentName = (document: DocumentData) => document.filename
|
||||
|
||||
const getDocumentDescription = (document: DocumentData) => {
|
||||
const statusMap: Record<string, string> = {
|
||||
pending: 'Processing pending',
|
||||
processing: 'Processing...',
|
||||
completed: 'Ready',
|
||||
failed: 'Processing failed',
|
||||
}
|
||||
|
||||
const status = statusMap[document.processingStatus] || document.processingStatus
|
||||
const chunkText = `${document.chunkCount} chunk${document.chunkCount !== 1 ? 's' : ''}`
|
||||
|
||||
return `${status} • ${chunkText}`
|
||||
}
|
||||
|
||||
const label = subBlock.placeholder || 'Select document'
|
||||
const isLoading = documentsLoading && !error
|
||||
|
||||
// Always use cached display name
|
||||
const displayName = useDisplayNamesStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (!normalizedKnowledgeBaseId || !value || typeof value !== 'string') return null
|
||||
return state.cache.documents[normalizedKnowledgeBaseId]?.[value] || null
|
||||
},
|
||||
[normalizedKnowledgeBaseId, value]
|
||||
)
|
||||
const selectorContext = useMemo<SelectorContext>(
|
||||
() => ({
|
||||
knowledgeBaseId: normalizedKnowledgeBaseId ?? undefined,
|
||||
}),
|
||||
[normalizedKnowledgeBaseId]
|
||||
)
|
||||
|
||||
const handleDocumentChange = useCallback(
|
||||
(documentId: string) => {
|
||||
if (isPreview) return
|
||||
onDocumentSelect?.(documentId)
|
||||
},
|
||||
[isPreview, onDocumentSelect]
|
||||
)
|
||||
|
||||
const missingKnowledgeBase = !normalizedKnowledgeBaseId
|
||||
const isDisabled = finalDisabled || missingKnowledgeBase
|
||||
const placeholder = subBlock.placeholder || 'Select document'
|
||||
|
||||
return (
|
||||
<div className='w-full'>
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
role='combobox'
|
||||
aria-expanded={open}
|
||||
className='relative w-full justify-between'
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div className='w-full'>
|
||||
<SelectorCombobox
|
||||
blockId={blockId}
|
||||
subBlock={subBlock}
|
||||
selectorKey='knowledge.documents'
|
||||
selectorContext={selectorContext}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<div className='flex max-w-[calc(100%-20px)] items-center gap-2 overflow-hidden'>
|
||||
<FileText className='h-4 w-4 text-muted-foreground' />
|
||||
{displayName ? (
|
||||
<span className='truncate font-normal'>{displayName}</span>
|
||||
) : (
|
||||
<span className='truncate text-muted-foreground'>{label}</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown className='absolute right-3 h-4 w-4 shrink-0 opacity-50' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-[300px] p-0' align='start'>
|
||||
<Command>
|
||||
<CommandInput placeholder='Search documents...' />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{isLoading ? (
|
||||
<div className='flex items-center justify-center p-4'>
|
||||
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||
<span className='ml-2'>Loading documents...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='text-destructive text-sm'>{error}</p>
|
||||
</div>
|
||||
) : !normalizedKnowledgeBaseId ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No knowledge base selected</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Please select a knowledge base first.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No documents found</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Upload documents to this knowledge base to get started.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CommandEmpty>
|
||||
|
||||
{documents.length > 0 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
Documents
|
||||
</div>
|
||||
{documents.map((document) => (
|
||||
<CommandItem
|
||||
key={document.id}
|
||||
value={`doc-${document.id}-${document.filename}`}
|
||||
onSelect={() => handleSelectDocument(document)}
|
||||
className='cursor-pointer'
|
||||
>
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
<FileText className='h-4 w-4 text-muted-foreground' />
|
||||
<div className='min-w-0 flex-1 overflow-hidden'>
|
||||
<div className='truncate font-normal'>{formatDocumentName(document)}</div>
|
||||
<div className='truncate text-muted-foreground text-xs'>
|
||||
{getDocumentDescription(document)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{document.id === value && <Check className='ml-auto h-4 w-4' />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
isPreview={isPreview}
|
||||
previewValue={previewValue ?? null}
|
||||
placeholder={placeholder}
|
||||
onOptionChange={handleDocumentChange}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
{missingKnowledgeBase && (
|
||||
<Tooltip.Content side='top'>
|
||||
<p>Select a knowledge base first.</p>
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,630 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Check, ChevronDown, ExternalLink, RefreshCw, X } from 'lucide-react'
|
||||
import { ConfluenceIcon } from '@/components/icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
type Credential,
|
||||
getProviderIdFromServiceId,
|
||||
getServiceIdFromScopes,
|
||||
type OAuthProvider,
|
||||
} from '@/lib/oauth'
|
||||
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
|
||||
import { useDisplayNamesStore } from '@/stores/display-names/store'
|
||||
|
||||
const logger = createLogger('ConfluenceFileSelector')
|
||||
|
||||
export interface ConfluenceFileInfo {
|
||||
id: string
|
||||
name: string
|
||||
mimeType: string
|
||||
webViewLink?: string
|
||||
modifiedTime?: string
|
||||
spaceId?: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
interface ConfluenceFileSelectorProps {
|
||||
value: string
|
||||
onChange: (value: string, fileInfo?: ConfluenceFileInfo) => void
|
||||
provider: OAuthProvider
|
||||
requiredScopes?: string[]
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
serviceId?: string
|
||||
domain: string
|
||||
showPreview?: boolean
|
||||
onFileInfoChange?: (fileInfo: ConfluenceFileInfo | null) => void
|
||||
credentialId?: string
|
||||
workflowId?: string
|
||||
isForeignCredential?: boolean
|
||||
}
|
||||
|
||||
export function ConfluenceFileSelector({
|
||||
value,
|
||||
onChange,
|
||||
provider,
|
||||
requiredScopes = [],
|
||||
label = 'Select Confluence page',
|
||||
disabled = false,
|
||||
serviceId,
|
||||
domain,
|
||||
showPreview = true,
|
||||
onFileInfoChange,
|
||||
credentialId,
|
||||
workflowId,
|
||||
isForeignCredential = false,
|
||||
}: ConfluenceFileSelectorProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [credentials, setCredentials] = useState<Credential[]>([])
|
||||
const [files, setFiles] = useState<ConfluenceFileInfo[]>([])
|
||||
const [selectedCredentialId, setSelectedCredentialId] = useState<string>(credentialId || '')
|
||||
const [selectedFileId, setSelectedFileId] = useState(value)
|
||||
const [selectedFile, setSelectedFile] = useState<ConfluenceFileInfo | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [showOAuthModal, setShowOAuthModal] = useState(false)
|
||||
const initialFetchRef = useRef(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Get cached display name
|
||||
const cachedFileName = useDisplayNamesStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
const effectiveCredentialId = credentialId || selectedCredentialId
|
||||
if (!effectiveCredentialId || !value) return null
|
||||
return state.cache.files[effectiveCredentialId]?.[value] || null
|
||||
},
|
||||
[credentialId, selectedCredentialId, value]
|
||||
)
|
||||
)
|
||||
// Keep internal credential in sync with prop (handles late arrival and BFCache restores)
|
||||
useEffect(() => {
|
||||
if (credentialId && credentialId !== selectedCredentialId) {
|
||||
setSelectedCredentialId(credentialId)
|
||||
}
|
||||
}, [credentialId, selectedCredentialId])
|
||||
|
||||
// Handle search with debounce
|
||||
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
// Clear any existing timeout
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current)
|
||||
}
|
||||
|
||||
// Set a new timeout
|
||||
searchTimeoutRef.current = setTimeout(() => {
|
||||
if (value.length > 2) {
|
||||
fetchFiles(value)
|
||||
} else if (value.length === 0) {
|
||||
fetchFiles()
|
||||
}
|
||||
}, 500) // 500ms debounce
|
||||
}
|
||||
|
||||
// Clean up the timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Determine the appropriate service ID based on provider and scopes
|
||||
const getServiceId = (): string => {
|
||||
if (serviceId) return serviceId
|
||||
return getServiceIdFromScopes(provider, requiredScopes)
|
||||
}
|
||||
|
||||
// Determine the appropriate provider ID based on service and scopes
|
||||
const getProviderId = (): string => {
|
||||
const effectiveServiceId = getServiceId()
|
||||
return getProviderIdFromServiceId(effectiveServiceId)
|
||||
}
|
||||
|
||||
// Fetch available credentials for this provider
|
||||
const fetchCredentials = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const providerId = getProviderId()
|
||||
const response = await fetch(`/api/auth/oauth/credentials?provider=${providerId}`)
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setCredentials(data.credentials)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching credentials:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [provider, getProviderId, selectedCredentialId])
|
||||
|
||||
// Fetch page info when we have a selected file ID
|
||||
const fetchPageInfo = useCallback(
|
||||
async (pageId: string) => {
|
||||
if (!selectedCredentialId || !domain) return
|
||||
|
||||
// Validate domain format
|
||||
const trimmedDomain = domain.trim().toLowerCase()
|
||||
if (!trimmedDomain.includes('.')) {
|
||||
setError(
|
||||
'Invalid domain format. Please provide the full domain (e.g., your-site.atlassian.net)'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// Get the access token from the selected credential
|
||||
const tokenResponse = await fetch('/api/auth/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
credentialId: selectedCredentialId,
|
||||
workflowId,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorData = await tokenResponse.json()
|
||||
throw new Error(errorData.error || 'Failed to get access token')
|
||||
}
|
||||
|
||||
const tokenData = await tokenResponse.json()
|
||||
const accessToken = tokenData.accessToken
|
||||
|
||||
// Use the access token to fetch the page info
|
||||
const response = await fetch('/api/tools/confluence/page', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
domain,
|
||||
accessToken,
|
||||
pageId,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to fetch page info')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const fileInfo: ConfluenceFileInfo = {
|
||||
id: data.id || pageId,
|
||||
name: data.title || `Page ${pageId}`,
|
||||
mimeType: 'confluence/page',
|
||||
webViewLink: `https://${domain}/wiki/pages/${data.id}`,
|
||||
modifiedTime: data.version?.when,
|
||||
spaceId: data.spaceId,
|
||||
url: `https://${domain}/wiki/pages/${data.id}`,
|
||||
}
|
||||
setSelectedFile(fileInfo)
|
||||
onFileInfoChange?.(fileInfo)
|
||||
|
||||
// Cache the page name in display names store
|
||||
if (selectedCredentialId) {
|
||||
useDisplayNamesStore
|
||||
.getState()
|
||||
.setDisplayNames('files', selectedCredentialId, { [fileInfo.id]: fileInfo.name })
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching page info:', error)
|
||||
setError((error as Error).message)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
},
|
||||
[selectedCredentialId, domain, onFileInfoChange, workflowId]
|
||||
)
|
||||
|
||||
// Fetch pages from Confluence
|
||||
const fetchFiles = useCallback(
|
||||
async (searchQuery?: string) => {
|
||||
if (!selectedCredentialId || !domain) return
|
||||
if (isForeignCredential) return
|
||||
|
||||
// Validate domain format
|
||||
const trimmedDomain = domain.trim().toLowerCase()
|
||||
if (!trimmedDomain.includes('.')) {
|
||||
setError(
|
||||
'Invalid domain format. Please provide the full domain (e.g., your-site.atlassian.net)'
|
||||
)
|
||||
setFiles([])
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// Get the access token from the selected credential
|
||||
const tokenResponse = await fetch('/api/auth/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
credentialId: selectedCredentialId,
|
||||
workflowId,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorData = await tokenResponse.json()
|
||||
logger.error('Access token error:', errorData)
|
||||
|
||||
// If there's a token error, we might need to reconnect the account
|
||||
setError('Authentication failed. Please reconnect your Confluence account.')
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const tokenData = await tokenResponse.json()
|
||||
const accessToken = tokenData.accessToken
|
||||
|
||||
if (!accessToken) {
|
||||
logger.error('No access token returned')
|
||||
setError('Authentication failed. Please reconnect your Confluence account.')
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Simply fetch pages directly using the endpoint
|
||||
const response = await fetch('/api/tools/confluence/pages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
domain,
|
||||
accessToken,
|
||||
title: searchQuery || undefined,
|
||||
limit: 50,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
logger.info('Confluence pages fetch unauthorized (expected for collaborator)')
|
||||
setFiles([])
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
logger.error('Confluence API error:', errorData)
|
||||
throw new Error(errorData.error || 'Failed to fetch pages')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
logger.info(`Received ${data.files?.length || 0} files from API`)
|
||||
setFiles(data.files || [])
|
||||
|
||||
// Cache file names in display names store
|
||||
if (selectedCredentialId && data.files) {
|
||||
const fileMap = data.files.reduce(
|
||||
(acc: Record<string, string>, file: ConfluenceFileInfo) => {
|
||||
acc[file.id] = file.name
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
useDisplayNamesStore.getState().setDisplayNames('files', selectedCredentialId, fileMap)
|
||||
}
|
||||
|
||||
// If we have a selected file ID, update state and notify parent
|
||||
if (selectedFileId) {
|
||||
const fileInfo = data.files.find((file: ConfluenceFileInfo) => file.id === selectedFileId)
|
||||
if (fileInfo) {
|
||||
setSelectedFile(fileInfo)
|
||||
onFileInfoChange?.(fileInfo)
|
||||
} else if (!searchQuery && selectedFileId) {
|
||||
// If we can't find the file in the list, try to fetch it directly
|
||||
fetchPageInfo(selectedFileId)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching pages:', error)
|
||||
setError((error as Error).message)
|
||||
setFiles([])
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
},
|
||||
[
|
||||
selectedCredentialId,
|
||||
domain,
|
||||
selectedFileId,
|
||||
onFileInfoChange,
|
||||
fetchPageInfo,
|
||||
workflowId,
|
||||
isForeignCredential,
|
||||
]
|
||||
)
|
||||
|
||||
// Fetch credentials on initial mount
|
||||
useEffect(() => {
|
||||
if (!initialFetchRef.current) {
|
||||
fetchCredentials()
|
||||
initialFetchRef.current = true
|
||||
}
|
||||
}, [fetchCredentials])
|
||||
|
||||
// Only fetch files when the dropdown is opened, not on credential selection
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
setOpen(isOpen)
|
||||
|
||||
// Only fetch files when opening the dropdown and if we have valid credentials and domain
|
||||
if (isOpen && !isForeignCredential && selectedCredentialId && domain && domain.includes('.')) {
|
||||
fetchFiles()
|
||||
}
|
||||
}
|
||||
|
||||
// Keep internal selectedFileId in sync with the value prop
|
||||
useEffect(() => {
|
||||
if (value !== selectedFileId) {
|
||||
setSelectedFileId(value)
|
||||
}
|
||||
}, [value])
|
||||
|
||||
// Clear callback when value is cleared
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
setSelectedFile(null)
|
||||
onFileInfoChange?.(null)
|
||||
}
|
||||
}, [value, onFileInfoChange])
|
||||
|
||||
// Fetch page info on mount if we have a value but no selectedFile state
|
||||
useEffect(() => {
|
||||
if (value && selectedCredentialId && domain && !selectedFile) {
|
||||
fetchPageInfo(value)
|
||||
}
|
||||
}, [value, selectedCredentialId, domain, selectedFile, fetchPageInfo])
|
||||
|
||||
// Handle file selection
|
||||
const handleSelectFile = (file: ConfluenceFileInfo) => {
|
||||
setSelectedFileId(file.id)
|
||||
setSelectedFile(file)
|
||||
onChange(file.id, file)
|
||||
onFileInfoChange?.(file)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
// Handle adding a new credential
|
||||
const handleAddCredential = () => {
|
||||
// Show the OAuth modal
|
||||
setShowOAuthModal(true)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
// Clear selection
|
||||
const handleClearSelection = () => {
|
||||
setSelectedFileId('')
|
||||
onChange('', undefined)
|
||||
onFileInfoChange?.(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-2'>
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
role='combobox'
|
||||
aria-expanded={open}
|
||||
className='h-10 w-full min-w-0 justify-between'
|
||||
disabled={disabled || !domain || isForeignCredential}
|
||||
>
|
||||
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
|
||||
{cachedFileName ? (
|
||||
<>
|
||||
<ConfluenceIcon className='h-4 w-4' />
|
||||
<span className='truncate font-normal'>{cachedFileName}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ConfluenceIcon className='h-4 w-4' />
|
||||
<span className='truncate text-muted-foreground'>{label}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
{!isForeignCredential && (
|
||||
<PopoverContent className='w-[300px] p-0' align='start'>
|
||||
{/* Current account indicator */}
|
||||
{selectedCredentialId && credentials.length > 0 && (
|
||||
<div className='flex items-center justify-between border-b px-3 py-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<ConfluenceIcon className='h-4 w-4' />
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
|
||||
'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
{credentials.length > 1 && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 px-2 text-xs'
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Switch
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Command>
|
||||
<CommandInput placeholder='Search pages...' onValueChange={handleSearch} />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{isLoading ? (
|
||||
<div className='flex items-center justify-center p-4'>
|
||||
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||
<span className='ml-2'>Loading pages...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='text-destructive text-sm'>{error}</p>
|
||||
</div>
|
||||
) : credentials.length === 0 ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No accounts connected.</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Connect a Confluence account to continue.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No pages found.</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Try a different search or account.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CommandEmpty>
|
||||
|
||||
{/* Account selection - only show if we have multiple accounts */}
|
||||
{credentials.length > 1 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
Switch Account
|
||||
</div>
|
||||
{credentials.map((cred) => (
|
||||
<CommandItem
|
||||
key={cred.id}
|
||||
value={`account-${cred.id}`}
|
||||
onSelect={() => setSelectedCredentialId(cred.id)}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<ConfluenceIcon className='h-4 w-4' />
|
||||
<span className='font-normal'>{cred.name}</span>
|
||||
</div>
|
||||
{cred.id === selectedCredentialId && (
|
||||
<Check className='ml-auto h-4 w-4' />
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Files list */}
|
||||
{files.length > 0 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
Pages
|
||||
</div>
|
||||
{files.map((file) => (
|
||||
<CommandItem
|
||||
key={file.id}
|
||||
value={`file-${file.id}-${file.name}`}
|
||||
onSelect={() => handleSelectFile(file)}
|
||||
>
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
<ConfluenceIcon className='h-4 w-4' />
|
||||
<span className='truncate font-normal'>{file.name}</span>
|
||||
</div>
|
||||
{file.id === selectedFileId && <Check className='ml-auto h-4 w-4' />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Connect account option - only show if no credentials */}
|
||||
{credentials.length === 0 && (
|
||||
<CommandGroup>
|
||||
<CommandItem onSelect={handleAddCredential}>
|
||||
<div className='flex items-center gap-2 text-foreground'>
|
||||
<ConfluenceIcon className='h-4 w-4' />
|
||||
<span>Connect Confluence account</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
)}
|
||||
</Popover>
|
||||
|
||||
{showPreview && selectedFile && selectedFileId && selectedFile.id === selectedFileId && (
|
||||
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
|
||||
<div className='absolute top-2 right-2'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-5 w-5 hover:bg-muted'
|
||||
onClick={handleClearSelection}
|
||||
>
|
||||
<X className='h-3 w-3' />
|
||||
</Button>
|
||||
</div>
|
||||
<div className='flex items-center gap-3 pr-4'>
|
||||
<div className='flex h-6 w-6 flex-shrink-0 items-center justify-center rounded bg-muted/20'>
|
||||
<ConfluenceIcon className='h-4 w-4' />
|
||||
</div>
|
||||
<div className='min-w-0 flex-1 overflow-hidden'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<h4 className='truncate font-medium text-xs'>{selectedFile.name}</h4>
|
||||
{selectedFile.modifiedTime && (
|
||||
<span className='whitespace-nowrap text-muted-foreground text-xs'>
|
||||
{new Date(selectedFile.modifiedTime).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{selectedFile.webViewLink && (
|
||||
<a
|
||||
href={selectedFile.webViewLink}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='flex items-center gap-1 text-foreground text-xs hover:underline'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span>Open in Confluence</span>
|
||||
<ExternalLink className='h-3 w-3' />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showOAuthModal && (
|
||||
<OAuthRequiredModal
|
||||
isOpen={showOAuthModal}
|
||||
onClose={() => setShowOAuthModal(false)}
|
||||
provider={provider}
|
||||
toolName='Confluence'
|
||||
requiredScopes={requiredScopes}
|
||||
serviceId={getServiceId()}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,288 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Check, ChevronDown, RefreshCw, X } from 'lucide-react'
|
||||
import { GoogleCalendarIcon } from '@/components/icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { useDisplayNamesStore } from '@/stores/display-names/store'
|
||||
|
||||
const logger = createLogger('GoogleCalendarSelector')
|
||||
|
||||
export interface GoogleCalendarInfo {
|
||||
id: string
|
||||
summary: string
|
||||
description?: string
|
||||
primary?: boolean
|
||||
accessRole: string
|
||||
backgroundColor?: string
|
||||
foregroundColor?: string
|
||||
}
|
||||
|
||||
interface GoogleCalendarSelectorProps {
|
||||
value: string
|
||||
onChange: (value: string, calendarInfo?: GoogleCalendarInfo) => void
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
showPreview?: boolean
|
||||
onCalendarInfoChange?: (info: GoogleCalendarInfo | null) => void
|
||||
credentialId: string
|
||||
workflowId?: string
|
||||
}
|
||||
|
||||
export function GoogleCalendarSelector({
|
||||
value,
|
||||
onChange,
|
||||
label = 'Select Google Calendar',
|
||||
disabled = false,
|
||||
showPreview = true,
|
||||
onCalendarInfoChange,
|
||||
credentialId,
|
||||
workflowId,
|
||||
}: GoogleCalendarSelectorProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [calendars, setCalendars] = useState<GoogleCalendarInfo[]>([])
|
||||
const [selectedCalendarId, setSelectedCalendarId] = useState(value)
|
||||
const [selectedCalendar, setSelectedCalendar] = useState<GoogleCalendarInfo | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [initialFetchDone, setInitialFetchDone] = useState(false)
|
||||
|
||||
// Get cached display name
|
||||
const cachedCalendarName = useDisplayNamesStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (!credentialId || !value) return null
|
||||
return state.cache.files[credentialId]?.[value] || null
|
||||
},
|
||||
[credentialId, value]
|
||||
)
|
||||
)
|
||||
|
||||
const fetchCalendarsFromAPI = useCallback(async (): Promise<GoogleCalendarInfo[]> => {
|
||||
if (!credentialId) {
|
||||
throw new Error('Google Calendar account is required')
|
||||
}
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
credentialId: credentialId,
|
||||
})
|
||||
if (workflowId) {
|
||||
queryParams.set('workflowId', workflowId)
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/tools/google_calendar/calendars?${queryParams.toString()}`)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to fetch Google Calendar calendars')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data.calendars || []
|
||||
}, [credentialId])
|
||||
|
||||
const fetchCalendars = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const calendars = await fetchCalendarsFromAPI()
|
||||
setCalendars(calendars)
|
||||
|
||||
// Cache calendar names
|
||||
if (credentialId && calendars.length > 0) {
|
||||
const calendarMap = calendars.reduce<Record<string, string>>((acc, cal) => {
|
||||
acc[cal.id] = cal.summary
|
||||
return acc
|
||||
}, {})
|
||||
useDisplayNamesStore.getState().setDisplayNames('files', credentialId, calendarMap)
|
||||
}
|
||||
|
||||
// Update selected calendar if we have a value
|
||||
if (selectedCalendarId && calendars.length > 0) {
|
||||
const calendar = calendars.find((c) => c.id === selectedCalendarId)
|
||||
setSelectedCalendar(calendar || null)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching calendars:', error)
|
||||
setError((error as Error).message)
|
||||
setCalendars([])
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
setInitialFetchDone(true)
|
||||
}
|
||||
}, [fetchCalendarsFromAPI, credentialId])
|
||||
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
setOpen(isOpen)
|
||||
|
||||
if (isOpen && credentialId && (!initialFetchDone || calendars.length === 0)) {
|
||||
fetchCalendars()
|
||||
}
|
||||
}
|
||||
|
||||
// Sync selected ID with external value
|
||||
useEffect(() => {
|
||||
if (value !== selectedCalendarId) {
|
||||
setSelectedCalendarId(value)
|
||||
}
|
||||
}, [value, selectedCalendarId])
|
||||
|
||||
// Handle calendar selection
|
||||
const handleSelectCalendar = (calendar: GoogleCalendarInfo) => {
|
||||
setSelectedCalendarId(calendar.id)
|
||||
setSelectedCalendar(calendar)
|
||||
onChange(calendar.id, calendar)
|
||||
onCalendarInfoChange?.(calendar)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
// Clear selection
|
||||
const handleClearSelection = () => {
|
||||
setSelectedCalendarId('')
|
||||
onChange('', undefined)
|
||||
onCalendarInfoChange?.(null)
|
||||
setError(null)
|
||||
}
|
||||
|
||||
// Get calendar display name
|
||||
const getCalendarDisplayName = (calendar: GoogleCalendarInfo) => {
|
||||
if (calendar.primary) {
|
||||
return `${calendar.summary} (Primary)`
|
||||
}
|
||||
return calendar.summary
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-2'>
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
role='combobox'
|
||||
aria-expanded={open}
|
||||
className='h-10 w-full min-w-0 justify-between'
|
||||
disabled={disabled || !credentialId}
|
||||
>
|
||||
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
|
||||
{cachedCalendarName ? (
|
||||
<>
|
||||
<GoogleCalendarIcon className='h-4 w-4' />
|
||||
<span className='truncate font-normal'>{cachedCalendarName}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GoogleCalendarIcon className='h-4 w-4' />
|
||||
<span className='truncate text-muted-foreground'>{label}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-[300px] p-0' align='start'>
|
||||
<Command>
|
||||
<CommandInput placeholder='Search calendars...' />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{isLoading ? (
|
||||
<div className='flex items-center justify-center p-4'>
|
||||
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||
<span className='ml-2'>Loading calendars...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='text-destructive text-sm'>{error}</p>
|
||||
</div>
|
||||
) : calendars.length === 0 ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No calendars found</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Please check your Google Calendar account access
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No matching calendars</p>
|
||||
</div>
|
||||
)}
|
||||
</CommandEmpty>
|
||||
|
||||
{calendars.length > 0 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
Calendars
|
||||
</div>
|
||||
{calendars.map((calendar) => (
|
||||
<CommandItem
|
||||
key={calendar.id}
|
||||
value={`calendar-${calendar.id}-${calendar.summary}`}
|
||||
onSelect={() => handleSelectCalendar(calendar)}
|
||||
className='cursor-pointer'
|
||||
>
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
<div
|
||||
className='h-3 w-3 flex-shrink-0 rounded-full'
|
||||
style={{
|
||||
backgroundColor: calendar.backgroundColor || '#4285f4',
|
||||
}}
|
||||
/>
|
||||
<span className='truncate font-normal'>
|
||||
{getCalendarDisplayName(calendar)}
|
||||
</span>
|
||||
</div>
|
||||
{calendar.id === selectedCalendarId && <Check className='ml-auto h-4 w-4' />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{showPreview && selectedCalendar && (
|
||||
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
|
||||
<div className='absolute top-2 right-2'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-5 w-5 hover:bg-muted'
|
||||
onClick={handleClearSelection}
|
||||
>
|
||||
<X className='h-3 w-3' />
|
||||
</Button>
|
||||
</div>
|
||||
<div className='flex items-center gap-3 pr-4'>
|
||||
<div className='flex h-6 w-6 flex-shrink-0 items-center justify-center rounded bg-muted/20'>
|
||||
<div
|
||||
className='h-3 w-3 rounded-full'
|
||||
style={{
|
||||
backgroundColor: selectedCalendar.backgroundColor || '#4285f4',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className='min-w-0 flex-1 overflow-hidden'>
|
||||
<h4 className='truncate font-medium text-xs'>
|
||||
{getCalendarDisplayName(selectedCalendar)}
|
||||
</h4>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
Access: {selectedCalendar.accessRole}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,572 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { ExternalLink, FileIcon, FolderIcon, RefreshCw, X } from 'lucide-react'
|
||||
import useDrivePicker from 'react-google-drive-picker'
|
||||
import { GoogleDocsIcon, GoogleSheetsIcon } from '@/components/icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { getEnv } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
type Credential,
|
||||
getProviderIdFromServiceId,
|
||||
getServiceByProviderAndId,
|
||||
getServiceIdFromScopes,
|
||||
OAUTH_PROVIDERS,
|
||||
type OAuthProvider,
|
||||
parseProvider,
|
||||
} from '@/lib/oauth'
|
||||
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
|
||||
import { useDisplayNamesStore } from '@/stores/display-names/store'
|
||||
|
||||
const logger = createLogger('GoogleDrivePicker')
|
||||
|
||||
export interface FileInfo {
|
||||
id: string
|
||||
name: string
|
||||
mimeType: string
|
||||
iconLink?: string
|
||||
webViewLink?: string
|
||||
thumbnailLink?: string
|
||||
createdTime?: string
|
||||
modifiedTime?: string
|
||||
size?: string
|
||||
owners?: { displayName: string; emailAddress: string }[]
|
||||
}
|
||||
|
||||
interface GoogleDrivePickerProps {
|
||||
value: string
|
||||
onChange: (value: string, fileInfo?: FileInfo) => void
|
||||
provider: OAuthProvider
|
||||
requiredScopes?: string[]
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
serviceId?: string
|
||||
mimeTypeFilter?: string
|
||||
showPreview?: boolean
|
||||
onFileInfoChange?: (fileInfo: FileInfo | null) => void
|
||||
clientId: string
|
||||
apiKey: string
|
||||
credentialId?: string
|
||||
workflowId?: string
|
||||
}
|
||||
|
||||
export function GoogleDrivePicker({
|
||||
value,
|
||||
onChange,
|
||||
provider,
|
||||
requiredScopes = [],
|
||||
label = 'Select file',
|
||||
disabled = false,
|
||||
serviceId,
|
||||
mimeTypeFilter,
|
||||
showPreview = true,
|
||||
onFileInfoChange,
|
||||
clientId,
|
||||
apiKey,
|
||||
credentialId,
|
||||
workflowId,
|
||||
}: GoogleDrivePickerProps) {
|
||||
const [credentials, setCredentials] = useState<Credential[]>([])
|
||||
const [selectedCredentialId, setSelectedCredentialId] = useState<string>('')
|
||||
const [selectedFileId, setSelectedFileId] = useState(value)
|
||||
const [selectedFile, setSelectedFile] = useState<FileInfo | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isLoadingSelectedFile, setIsLoadingSelectedFile] = useState(false)
|
||||
const [showOAuthModal, setShowOAuthModal] = useState(false)
|
||||
const [credentialsLoaded, setCredentialsLoaded] = useState(false)
|
||||
const initialFetchRef = useRef(false)
|
||||
const [openPicker, _authResponse] = useDrivePicker()
|
||||
|
||||
// Determine the appropriate service ID based on provider and scopes
|
||||
const getServiceId = (): string => {
|
||||
if (serviceId) return serviceId
|
||||
return getServiceIdFromScopes(provider, requiredScopes)
|
||||
}
|
||||
|
||||
// Determine the appropriate provider ID based on service and scopes
|
||||
const getProviderId = (): string => {
|
||||
const effectiveServiceId = getServiceId()
|
||||
return getProviderIdFromServiceId(effectiveServiceId)
|
||||
}
|
||||
|
||||
// Fetch available credentials for this provider
|
||||
const fetchCredentials = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
setCredentialsLoaded(false)
|
||||
try {
|
||||
const providerId = getProviderId()
|
||||
const response = await fetch(`/api/auth/oauth/credentials?provider=${providerId}`)
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setCredentials(data.credentials)
|
||||
|
||||
const credentialMap = (data.credentials || []).reduce(
|
||||
(acc: Record<string, string>, cred: Credential) => {
|
||||
acc[cred.id] = cred.name
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
useDisplayNamesStore.getState().setDisplayNames('credentials', providerId, credentialMap)
|
||||
if (credentialId && !data.credentials.some((c: any) => c.id === credentialId)) {
|
||||
setSelectedCredentialId('')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching credentials:', { error })
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
setCredentialsLoaded(true)
|
||||
}
|
||||
}, [provider, getProviderId, selectedCredentialId])
|
||||
|
||||
// Prefer persisted credentialId if provided
|
||||
useEffect(() => {
|
||||
if (credentialId && credentialId !== selectedCredentialId) {
|
||||
setSelectedCredentialId(credentialId)
|
||||
}
|
||||
}, [credentialId, selectedCredentialId])
|
||||
|
||||
// Fetch a single file by ID when we have a selectedFileId but no metadata
|
||||
const fetchFileById = useCallback(
|
||||
async (fileId: string) => {
|
||||
if (!selectedCredentialId || !fileId) return null
|
||||
|
||||
setIsLoadingSelectedFile(true)
|
||||
try {
|
||||
// Construct query parameters
|
||||
const queryParams = new URLSearchParams({
|
||||
credentialId: selectedCredentialId,
|
||||
fileId: fileId,
|
||||
})
|
||||
if (workflowId) queryParams.set('workflowId', workflowId)
|
||||
|
||||
const response = await fetch(`/api/tools/drive/file?${queryParams.toString()}`)
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
if (data.file) {
|
||||
setSelectedFile(data.file)
|
||||
onFileInfoChange?.(data.file)
|
||||
|
||||
// Cache the file name
|
||||
if (selectedCredentialId && data.file.id && data.file.name) {
|
||||
useDisplayNamesStore.getState().setDisplayNames('files', selectedCredentialId, {
|
||||
[data.file.id]: data.file.name,
|
||||
})
|
||||
}
|
||||
|
||||
return data.file
|
||||
}
|
||||
} else {
|
||||
const errorText = await response.text()
|
||||
logger.error('Error fetching file by ID:', { error: errorText })
|
||||
|
||||
// If file not found or access denied, clear the selection
|
||||
if (response.status === 404 || response.status === 403) {
|
||||
logger.info('File not accessible, clearing selection')
|
||||
setSelectedFileId('')
|
||||
onChange('')
|
||||
onFileInfoChange?.(null)
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
logger.info('Credential unauthorized (401), clearing selection and prompting re-auth')
|
||||
setSelectedFileId('')
|
||||
onChange('')
|
||||
onFileInfoChange?.(null)
|
||||
setShowOAuthModal(true)
|
||||
}
|
||||
}
|
||||
return null
|
||||
} catch (error) {
|
||||
logger.error('Error fetching file by ID:', { error })
|
||||
return null
|
||||
} finally {
|
||||
setIsLoadingSelectedFile(false)
|
||||
}
|
||||
},
|
||||
[selectedCredentialId, onChange, onFileInfoChange]
|
||||
)
|
||||
|
||||
// Fetch credentials on initial mount
|
||||
useEffect(() => {
|
||||
if (!initialFetchRef.current) {
|
||||
fetchCredentials()
|
||||
initialFetchRef.current = true
|
||||
}
|
||||
}, [fetchCredentials])
|
||||
|
||||
// Keep internal selectedFileId in sync with the value prop
|
||||
useEffect(() => {
|
||||
if (value !== selectedFileId) {
|
||||
const previousFileId = selectedFileId
|
||||
setSelectedFileId(value)
|
||||
// Only clear selected file info if we had a different file before (not initial load)
|
||||
if (previousFileId && previousFileId !== value && selectedFile) {
|
||||
setSelectedFile(null)
|
||||
}
|
||||
}
|
||||
}, [value, selectedFileId, selectedFile])
|
||||
|
||||
// Track previous credential ID to detect changes
|
||||
const prevCredentialIdRef = useRef<string>('')
|
||||
|
||||
// Clear selected file when credentials are removed or changed
|
||||
useEffect(() => {
|
||||
const prevCredentialId = prevCredentialIdRef.current
|
||||
prevCredentialIdRef.current = selectedCredentialId
|
||||
|
||||
if (!selectedCredentialId) {
|
||||
// No credentials - clear everything
|
||||
if (selectedFile) {
|
||||
setSelectedFile(null)
|
||||
setSelectedFileId('')
|
||||
onChange('')
|
||||
}
|
||||
} else if (prevCredentialId && prevCredentialId !== selectedCredentialId) {
|
||||
// Credentials changed (not initial load) - clear file info to force refetch
|
||||
if (selectedFile) {
|
||||
setSelectedFile(null)
|
||||
}
|
||||
}
|
||||
}, [selectedCredentialId, selectedFile, onChange])
|
||||
|
||||
// Fetch the selected file metadata once credentials are loaded or changed
|
||||
useEffect(() => {
|
||||
// Only fetch if we have both a file ID and credentials, credentials are loaded, but no file info yet
|
||||
if (
|
||||
value &&
|
||||
selectedCredentialId &&
|
||||
credentialsLoaded &&
|
||||
!selectedFile &&
|
||||
!isLoadingSelectedFile
|
||||
) {
|
||||
fetchFileById(value)
|
||||
}
|
||||
}, [
|
||||
value,
|
||||
selectedCredentialId,
|
||||
credentialsLoaded,
|
||||
selectedFile,
|
||||
isLoadingSelectedFile,
|
||||
fetchFileById,
|
||||
])
|
||||
|
||||
// Fetch the access token for the selected credential
|
||||
const fetchAccessToken = async (credentialOverrideId?: string): Promise<string | null> => {
|
||||
const effectiveCredentialId = credentialOverrideId || selectedCredentialId
|
||||
if (!effectiveCredentialId) {
|
||||
logger.error('No credential ID selected for Google Drive Picker')
|
||||
return null
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const response = await fetch('/api/auth/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ credentialId: effectiveCredentialId, workflowId }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch access token: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data.accessToken || null
|
||||
} catch (error) {
|
||||
logger.error('Error fetching access token:', { error })
|
||||
return null
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle opening the Google Drive Picker
|
||||
const handleOpenPicker = async (credentialOverrideId?: string) => {
|
||||
try {
|
||||
// First, get the access token for the selected credential
|
||||
const accessToken = await fetchAccessToken(credentialOverrideId)
|
||||
|
||||
if (!accessToken) {
|
||||
logger.error('Failed to get access token for Google Drive Picker')
|
||||
return
|
||||
}
|
||||
|
||||
const viewIdForMimeType = () => {
|
||||
// Return appropriate view based on mime type filter
|
||||
if (mimeTypeFilter?.includes('folder')) {
|
||||
return 'FOLDERS'
|
||||
}
|
||||
if (mimeTypeFilter?.includes('spreadsheet')) {
|
||||
return 'SPREADSHEETS'
|
||||
}
|
||||
if (mimeTypeFilter?.includes('document')) {
|
||||
return 'DOCUMENTS'
|
||||
}
|
||||
return 'DOCS' // Default view
|
||||
}
|
||||
|
||||
openPicker({
|
||||
clientId,
|
||||
developerKey: apiKey,
|
||||
viewId: viewIdForMimeType(),
|
||||
token: accessToken, // Use the fetched access token
|
||||
showUploadView: true,
|
||||
showUploadFolders: true,
|
||||
supportDrives: true,
|
||||
multiselect: false,
|
||||
appId: getEnv('NEXT_PUBLIC_GOOGLE_PROJECT_NUMBER'),
|
||||
// Enable folder selection when mimeType is folder
|
||||
setSelectFolderEnabled: !!mimeTypeFilter?.includes('folder'),
|
||||
callbackFunction: (data) => {
|
||||
if (data.action === 'picked') {
|
||||
const file = data.docs[0]
|
||||
if (file) {
|
||||
const fileInfo: FileInfo = {
|
||||
id: file.id,
|
||||
name: file.name,
|
||||
mimeType: file.mimeType,
|
||||
iconLink: file.iconUrl,
|
||||
webViewLink: file.url,
|
||||
// thumbnailLink is not directly available from the picker
|
||||
thumbnailLink: file.iconUrl, // Use iconUrl as fallback
|
||||
modifiedTime: file.lastEditedUtc
|
||||
? new Date(file.lastEditedUtc).toISOString()
|
||||
: undefined,
|
||||
}
|
||||
|
||||
setSelectedFileId(file.id)
|
||||
setSelectedFile(fileInfo)
|
||||
onChange(file.id, fileInfo)
|
||||
onFileInfoChange?.(fileInfo)
|
||||
|
||||
// Cache the selected file name
|
||||
if (selectedCredentialId) {
|
||||
useDisplayNamesStore
|
||||
.getState()
|
||||
.setDisplayNames('files', selectedCredentialId, { [file.id]: file.name })
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error opening Google Drive Picker:', { error })
|
||||
}
|
||||
}
|
||||
|
||||
// Handle adding a new credential
|
||||
const handleAddCredential = () => {
|
||||
// Show the OAuth modal
|
||||
setShowOAuthModal(true)
|
||||
}
|
||||
|
||||
// Clear selection
|
||||
const handleClearSelection = () => {
|
||||
setSelectedFileId('')
|
||||
setSelectedFile(null)
|
||||
onChange('', undefined)
|
||||
onFileInfoChange?.(null)
|
||||
}
|
||||
|
||||
// Get provider icon
|
||||
const getProviderIcon = (providerName: OAuthProvider) => {
|
||||
const { baseProvider } = parseProvider(providerName)
|
||||
const baseProviderConfig = OAUTH_PROVIDERS[baseProvider]
|
||||
|
||||
if (!baseProviderConfig) {
|
||||
return <ExternalLink className='h-4 w-4' />
|
||||
}
|
||||
|
||||
// For compound providers, find the specific service
|
||||
if (providerName.includes('-')) {
|
||||
for (const service of Object.values(baseProviderConfig.services)) {
|
||||
if (service.providerId === providerName) {
|
||||
return service.icon({ className: 'h-4 w-4' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to base provider icon
|
||||
return baseProviderConfig.icon({ className: 'h-4 w-4' })
|
||||
}
|
||||
|
||||
// Get provider name
|
||||
const getProviderName = (providerName: OAuthProvider) => {
|
||||
const effectiveServiceId = getServiceId()
|
||||
try {
|
||||
// First try to get the service by provider and service ID
|
||||
const service = getServiceByProviderAndId(providerName, effectiveServiceId)
|
||||
return service.name
|
||||
} catch (_error) {
|
||||
// If that fails, try to get the service by parsing the provider
|
||||
try {
|
||||
const { baseProvider } = parseProvider(providerName)
|
||||
const baseProviderConfig = OAUTH_PROVIDERS[baseProvider]
|
||||
|
||||
// For compound providers like 'google-drive', try to find the specific service
|
||||
if (providerName.includes('-')) {
|
||||
const serviceKey = providerName.split('-')[1] || ''
|
||||
for (const [key, service] of Object.entries(baseProviderConfig?.services || {})) {
|
||||
if (key === serviceKey || key === providerName || service.providerId === providerName) {
|
||||
return service.name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to provider name if service not found
|
||||
if (baseProviderConfig) {
|
||||
return baseProviderConfig.name
|
||||
}
|
||||
} catch (_parseError) {
|
||||
// Ignore parse error and continue to final fallback
|
||||
}
|
||||
|
||||
// Final fallback: capitalize the provider name
|
||||
return providerName
|
||||
.split('-')
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
}
|
||||
|
||||
// Get file icon based on mime type
|
||||
const getFileIcon = (file: FileInfo, size: 'sm' | 'md' = 'sm') => {
|
||||
const iconSize = size === 'sm' ? 'h-4 w-4' : 'h-5 w-5'
|
||||
|
||||
if (file.mimeType === 'application/vnd.google-apps.folder') {
|
||||
return <FolderIcon className={`${iconSize} text-muted-foreground`} />
|
||||
}
|
||||
if (file.mimeType === 'application/vnd.google-apps.spreadsheet') {
|
||||
return <GoogleSheetsIcon className={iconSize} />
|
||||
}
|
||||
if (file.mimeType === 'application/vnd.google-apps.document') {
|
||||
return <GoogleDocsIcon className={iconSize} />
|
||||
}
|
||||
return <FileIcon className={`${iconSize} text-muted-foreground`} />
|
||||
}
|
||||
|
||||
const canShowPreview = !!(
|
||||
showPreview &&
|
||||
selectedFile &&
|
||||
selectedFileId &&
|
||||
selectedFile.id === selectedFileId
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
role='combobox'
|
||||
className='h-10 w-full min-w-0 justify-between'
|
||||
disabled={disabled || isLoading}
|
||||
onClick={async () => {
|
||||
// Decide which credential to use
|
||||
let idToUse = selectedCredentialId
|
||||
if (!idToUse && credentials.length === 1) {
|
||||
idToUse = credentials[0].id
|
||||
setSelectedCredentialId(idToUse)
|
||||
}
|
||||
|
||||
if (!idToUse) {
|
||||
// No credentials — prompt OAuth
|
||||
handleAddCredential()
|
||||
return
|
||||
}
|
||||
|
||||
await handleOpenPicker(idToUse)
|
||||
}}
|
||||
>
|
||||
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
|
||||
{canShowPreview ? (
|
||||
<>
|
||||
{getFileIcon(selectedFile, 'sm')}
|
||||
<span className='truncate font-normal'>{selectedFile.name}</span>
|
||||
</>
|
||||
) : selectedFileId && isLoadingSelectedFile && selectedCredentialId ? (
|
||||
<>
|
||||
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||
<span className='truncate text-muted-foreground'>Loading document...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{getProviderIcon(provider)}
|
||||
<span className='truncate text-muted-foreground'>{label}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{/* File preview */}
|
||||
{canShowPreview && (
|
||||
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
|
||||
<div className='absolute top-2 right-2'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-5 w-5 hover:bg-muted'
|
||||
onClick={handleClearSelection}
|
||||
>
|
||||
<X className='h-3 w-3' />
|
||||
</Button>
|
||||
</div>
|
||||
<div className='flex items-center gap-3 pr-4'>
|
||||
<div className='flex h-6 w-6 flex-shrink-0 items-center justify-center rounded bg-muted/20'>
|
||||
{getFileIcon(selectedFile, 'sm')}
|
||||
</div>
|
||||
<div className='min-w-0 flex-1 overflow-hidden'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<h4 className='truncate font-medium text-xs'>{selectedFile.name}</h4>
|
||||
{selectedFile.modifiedTime && (
|
||||
<span className='whitespace-nowrap text-muted-foreground text-xs'>
|
||||
{new Date(selectedFile.modifiedTime).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{selectedFile.webViewLink ? (
|
||||
<a
|
||||
href={selectedFile.webViewLink}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='flex items-center gap-1 text-muted-foreground text-xs hover:underline'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span>Open in Drive</span>
|
||||
<ExternalLink className='h-3 w-3' />
|
||||
</a>
|
||||
) : (
|
||||
<a
|
||||
href={`https://drive.google.com/file/d/${selectedFile.id}/view`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='flex items-center gap-1 text-muted-foreground text-xs hover:underline'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span>Open in Drive</span>
|
||||
<ExternalLink className='h-3 w-3' />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showOAuthModal && (
|
||||
<OAuthRequiredModal
|
||||
isOpen={showOAuthModal}
|
||||
onClose={() => setShowOAuthModal(false)}
|
||||
provider={provider}
|
||||
toolName={getProviderName(provider)}
|
||||
requiredScopes={requiredScopes}
|
||||
serviceId={getServiceId()}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
export type { ConfluenceFileInfo } from './confluence-file-selector'
|
||||
export { ConfluenceFileSelector } from './confluence-file-selector'
|
||||
export type { GoogleCalendarInfo } from './google-calendar-selector'
|
||||
export { GoogleCalendarSelector } from './google-calendar-selector'
|
||||
export type { FileInfo } from './google-drive-picker'
|
||||
export { GoogleDrivePicker } from './google-drive-picker'
|
||||
export type { JiraIssueInfo } from './jira-issue-selector'
|
||||
export { JiraIssueSelector } from './jira-issue-selector'
|
||||
export type { MicrosoftFileInfo } from './microsoft-file-selector'
|
||||
export { MicrosoftFileSelector } from './microsoft-file-selector'
|
||||
export type { TeamsMessageInfo } from './teams-message-selector'
|
||||
export { TeamsMessageSelector } from './teams-message-selector'
|
||||
export type { WealthboxItemInfo } from './wealthbox-file-selector'
|
||||
export { WealthboxFileSelector } from './wealthbox-file-selector'
|
||||
@@ -1,670 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Check, ChevronDown, ExternalLink, RefreshCw, X } from 'lucide-react'
|
||||
import { JiraIcon } from '@/components/icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
type Credential,
|
||||
getProviderIdFromServiceId,
|
||||
getServiceIdFromScopes,
|
||||
type OAuthProvider,
|
||||
} from '@/lib/oauth'
|
||||
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
|
||||
import { useDisplayNamesStore } from '@/stores/display-names/store'
|
||||
|
||||
const logger = createLogger('JiraIssueSelector')
|
||||
|
||||
export interface JiraIssueInfo {
|
||||
id: string
|
||||
name: string
|
||||
mimeType: string
|
||||
webViewLink?: string
|
||||
modifiedTime?: string
|
||||
spaceId?: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
interface JiraIssueSelectorProps {
|
||||
value: string
|
||||
onChange: (value: string, issueInfo?: JiraIssueInfo) => void
|
||||
provider: OAuthProvider
|
||||
requiredScopes?: string[]
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
serviceId?: string
|
||||
domain: string
|
||||
showPreview?: boolean
|
||||
onIssueInfoChange?: (issueInfo: JiraIssueInfo | null) => void
|
||||
projectId?: string
|
||||
credentialId?: string
|
||||
isForeignCredential?: boolean
|
||||
workflowId?: string
|
||||
}
|
||||
|
||||
export function JiraIssueSelector({
|
||||
value,
|
||||
onChange,
|
||||
provider,
|
||||
requiredScopes = [],
|
||||
label = 'Select Jira issue',
|
||||
disabled = false,
|
||||
serviceId,
|
||||
domain,
|
||||
showPreview = true,
|
||||
onIssueInfoChange,
|
||||
projectId,
|
||||
credentialId,
|
||||
isForeignCredential = false,
|
||||
workflowId,
|
||||
}: JiraIssueSelectorProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [credentials, setCredentials] = useState<Credential[]>([])
|
||||
const [issues, setIssues] = useState<JiraIssueInfo[]>([])
|
||||
const [selectedCredentialId, setSelectedCredentialId] = useState<string>(credentialId || '')
|
||||
const [selectedIssueId, setSelectedIssueId] = useState(value)
|
||||
const [selectedIssue, setSelectedIssue] = useState<JiraIssueInfo | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [showOAuthModal, setShowOAuthModal] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [cloudId, setCloudId] = useState<string | null>(null)
|
||||
|
||||
// Get cached display name
|
||||
const cachedIssueName = useDisplayNamesStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
const effectiveCredentialId = credentialId || selectedCredentialId
|
||||
if (!effectiveCredentialId || !value) return null
|
||||
return state.cache.files[effectiveCredentialId]?.[value] || null
|
||||
},
|
||||
[credentialId, selectedCredentialId, value]
|
||||
)
|
||||
)
|
||||
|
||||
// Keep local credential state in sync with persisted credentialId prop
|
||||
useEffect(() => {
|
||||
if (credentialId && credentialId !== selectedCredentialId) {
|
||||
setSelectedCredentialId(credentialId)
|
||||
} else if (!credentialId && selectedCredentialId) {
|
||||
setSelectedCredentialId('')
|
||||
}
|
||||
}, [credentialId, selectedCredentialId])
|
||||
|
||||
// Handle search with debounce
|
||||
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
// Clear any existing timeout
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current)
|
||||
}
|
||||
|
||||
// Set a new timeout
|
||||
searchTimeoutRef.current = setTimeout(() => {
|
||||
if (value.length >= 1) {
|
||||
// Changed from > 2 to >= 1 to be more responsive
|
||||
fetchIssues(value)
|
||||
} else {
|
||||
setIssues([]) // Clear issues if search is empty
|
||||
}
|
||||
}, 500) // 500ms debounce
|
||||
}
|
||||
|
||||
// Clean up the timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Determine the appropriate service ID based on provider and scopes
|
||||
const getServiceId = (): string => {
|
||||
if (serviceId) return serviceId
|
||||
return getServiceIdFromScopes(provider, requiredScopes)
|
||||
}
|
||||
|
||||
// Determine the appropriate provider ID based on service and scopes (stabilized)
|
||||
const providerId = useMemo(() => {
|
||||
const effectiveServiceId = getServiceId()
|
||||
return getProviderIdFromServiceId(effectiveServiceId)
|
||||
}, [serviceId, provider, requiredScopes])
|
||||
|
||||
// Fetch available credentials for this provider
|
||||
const fetchCredentials = useCallback(async () => {
|
||||
if (!providerId) return
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const response = await fetch(`/api/auth/oauth/credentials?provider=${providerId}`)
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setCredentials(data.credentials)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching credentials:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [providerId])
|
||||
|
||||
// Fetch issue info when we have a selected issue ID
|
||||
const fetchIssueInfo = useCallback(
|
||||
async (issueId: string) => {
|
||||
// Validate domain format
|
||||
const trimmedDomain = domain.trim().toLowerCase()
|
||||
if (!trimmedDomain.includes('.')) {
|
||||
setError(
|
||||
'Invalid domain format. Please provide the full domain (e.g., your-site.atlassian.net)'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// Get the access token from the selected credential
|
||||
const tokenResponse = await fetch('/api/auth/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
credentialId: selectedCredentialId,
|
||||
workflowId,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorData = await tokenResponse.json()
|
||||
throw new Error(errorData.error || 'Failed to get access token')
|
||||
}
|
||||
|
||||
const tokenData = await tokenResponse.json()
|
||||
const accessToken = tokenData.accessToken
|
||||
|
||||
if (!accessToken) {
|
||||
throw new Error('No access token received')
|
||||
}
|
||||
|
||||
// Use the access token to fetch the issue info
|
||||
const response = await fetch('/api/tools/jira/issue', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
domain,
|
||||
accessToken,
|
||||
issueId,
|
||||
cloudId,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
logger.error('Failed to fetch issue info:', errorData)
|
||||
throw new Error(errorData.error || 'Failed to fetch issue info')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
if (data.cloudId) {
|
||||
logger.info('Using cloud ID:', data.cloudId)
|
||||
setCloudId(data.cloudId)
|
||||
}
|
||||
|
||||
if (data.issue) {
|
||||
logger.info('Successfully fetched issue:', data.issue.name)
|
||||
setSelectedIssue(data.issue)
|
||||
onIssueInfoChange?.(data.issue)
|
||||
} else {
|
||||
logger.warn('No issue data received in response')
|
||||
setSelectedIssue(null)
|
||||
onIssueInfoChange?.(null)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching issue info:', error)
|
||||
setError((error as Error).message)
|
||||
onIssueInfoChange?.(null)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
},
|
||||
[selectedCredentialId, domain, onIssueInfoChange, cloudId]
|
||||
)
|
||||
|
||||
// Fetch issues from Jira
|
||||
const fetchIssues = useCallback(
|
||||
async (searchQuery?: string) => {
|
||||
if (!selectedCredentialId || !domain) return
|
||||
// If no search query is provided, require a projectId before fetching
|
||||
if (!searchQuery && !projectId) {
|
||||
setIssues([])
|
||||
return
|
||||
}
|
||||
|
||||
// Validate domain format
|
||||
const trimmedDomain = domain.trim().toLowerCase()
|
||||
if (!trimmedDomain.includes('.')) {
|
||||
setError(
|
||||
'Invalid domain format. Please provide the full domain (e.g., your-site.atlassian.net)'
|
||||
)
|
||||
setIssues([])
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// Get the access token from the selected credential
|
||||
const tokenResponse = await fetch('/api/auth/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
credentialId: selectedCredentialId,
|
||||
workflowId,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorData = await tokenResponse.json()
|
||||
logger.error('Access token error:', errorData)
|
||||
|
||||
// If there's a token error, we might need to reconnect the account
|
||||
setError('Authentication failed. Please reconnect your Jira account.')
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const tokenData = await tokenResponse.json()
|
||||
const accessToken = tokenData.accessToken
|
||||
|
||||
if (!accessToken) {
|
||||
logger.error('No access token returned')
|
||||
setError('Authentication failed. Please reconnect your Jira account.')
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Build query parameters for the issues endpoint
|
||||
const queryParams = new URLSearchParams({
|
||||
domain,
|
||||
accessToken,
|
||||
...(projectId && { projectId }),
|
||||
...(searchQuery && { query: searchQuery }),
|
||||
...(cloudId && { cloudId }),
|
||||
})
|
||||
|
||||
const response = await fetch(`/api/tools/jira/issues?${queryParams.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
logger.error('Jira API error:', errorData)
|
||||
throw new Error(errorData.error || 'Failed to fetch issues')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.cloudId) {
|
||||
setCloudId(data.cloudId)
|
||||
}
|
||||
|
||||
// Process the issue picker results
|
||||
let foundIssues: JiraIssueInfo[] = []
|
||||
|
||||
// Handle the sections returned by the issue picker API
|
||||
if (data.sections) {
|
||||
// Combine issues from all sections
|
||||
data.sections.forEach((section: any) => {
|
||||
if (section.issues && section.issues.length > 0) {
|
||||
const sectionIssues = section.issues.map((issue: any) => ({
|
||||
id: issue.key,
|
||||
name: issue.summary || issue.summaryText || issue.key,
|
||||
mimeType: 'jira/issue',
|
||||
url: `https://${domain}/browse/${issue.key}`,
|
||||
webViewLink: `https://${domain}/browse/${issue.key}`,
|
||||
}))
|
||||
foundIssues = [...foundIssues, ...sectionIssues]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
logger.info(`Received ${foundIssues.length} issues from API`)
|
||||
setIssues(foundIssues)
|
||||
|
||||
// Cache issue names in display names store
|
||||
if (selectedCredentialId && foundIssues.length > 0) {
|
||||
const issueMap = foundIssues.reduce(
|
||||
(acc: Record<string, string>, issue: JiraIssueInfo) => {
|
||||
acc[issue.id] = issue.name
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
useDisplayNamesStore.getState().setDisplayNames('files', selectedCredentialId, issueMap)
|
||||
}
|
||||
|
||||
// If we have a selected issue ID, update state and notify parent
|
||||
if (selectedIssueId) {
|
||||
const issueInfo = foundIssues.find((issue: JiraIssueInfo) => issue.id === selectedIssueId)
|
||||
if (issueInfo) {
|
||||
setSelectedIssue(issueInfo)
|
||||
onIssueInfoChange?.(issueInfo)
|
||||
} else if (!searchQuery && selectedIssueId) {
|
||||
// If we can't find the issue in the list, try to fetch it directly
|
||||
fetchIssueInfo(selectedIssueId)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching issues:', error)
|
||||
setError((error as Error).message)
|
||||
setIssues([])
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
},
|
||||
[
|
||||
selectedCredentialId,
|
||||
domain,
|
||||
selectedIssueId,
|
||||
onIssueInfoChange,
|
||||
fetchIssueInfo,
|
||||
cloudId,
|
||||
projectId,
|
||||
]
|
||||
)
|
||||
|
||||
// Fetch credentials when the dropdown opens (avoid fetching on mount with no credential)
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchCredentials()
|
||||
}
|
||||
}, [open, fetchCredentials])
|
||||
|
||||
// Handle open change
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
if (disabled || isForeignCredential) {
|
||||
setOpen(false)
|
||||
return
|
||||
}
|
||||
setOpen(isOpen)
|
||||
|
||||
// Only fetch recent/default issues when opening the dropdown
|
||||
if (isOpen && selectedCredentialId && domain && domain.includes('.')) {
|
||||
// Only fetch on open when a project is selected; otherwise wait for user search
|
||||
if (projectId) {
|
||||
fetchIssues('')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch selected issue metadata once credentials are ready or changed
|
||||
// Keep internal selectedIssueId in sync with the value prop
|
||||
useEffect(() => {
|
||||
if (value !== selectedIssueId) {
|
||||
setSelectedIssueId(value)
|
||||
}
|
||||
// When the upstream value is cleared (e.g., project changed or remote user cleared),
|
||||
// clear local selection and preview immediately
|
||||
if (!value) {
|
||||
setSelectedIssue(null)
|
||||
setIssues([])
|
||||
setError(null)
|
||||
onIssueInfoChange?.(null)
|
||||
}
|
||||
}, [value, onIssueInfoChange])
|
||||
|
||||
// Fetch issue info on mount if we have a value but no selectedIssue state
|
||||
useEffect(() => {
|
||||
if (value && selectedCredentialId && domain && projectId && !selectedIssue) {
|
||||
fetchIssueInfo(value)
|
||||
}
|
||||
}, [value, selectedCredentialId, domain, projectId, selectedIssue, fetchIssueInfo])
|
||||
|
||||
// Handle issue selection
|
||||
const handleSelectIssue = (issue: JiraIssueInfo) => {
|
||||
setSelectedIssueId(issue.id)
|
||||
setSelectedIssue(issue)
|
||||
onChange(issue.id, issue)
|
||||
onIssueInfoChange?.(issue)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
// Handle adding a new credential
|
||||
const handleAddCredential = () => {
|
||||
// Show the OAuth modal
|
||||
setShowOAuthModal(true)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
// Clear selection
|
||||
const handleClearSelection = () => {
|
||||
setSelectedIssueId('')
|
||||
setError(null)
|
||||
onChange('', undefined)
|
||||
onIssueInfoChange?.(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-2'>
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
role='combobox'
|
||||
aria-expanded={open}
|
||||
className='h-10 w-full min-w-0 justify-between'
|
||||
disabled={disabled || !domain || !selectedCredentialId || isForeignCredential}
|
||||
>
|
||||
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
|
||||
{cachedIssueName ? (
|
||||
<>
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
<span className='truncate font-normal'>{cachedIssueName}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
<span className='truncate text-muted-foreground'>{label}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
{!isForeignCredential && (
|
||||
<PopoverContent className='w-[300px] p-0' align='start'>
|
||||
{/* Current account indicator */}
|
||||
{selectedCredentialId && credentials.length > 0 && (
|
||||
<div className='flex items-center justify-between border-b px-3 py-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
|
||||
'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
{credentials.length > 1 && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 px-2 text-xs'
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Switch
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Command>
|
||||
<CommandInput placeholder='Search issues...' onValueChange={handleSearch} />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{isLoading ? (
|
||||
<div className='flex items-center justify-center p-4'>
|
||||
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||
<span className='ml-2'>Loading issues...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='text-destructive text-sm'>{error}</p>
|
||||
</div>
|
||||
) : credentials.length === 0 ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No accounts connected.</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Connect a Jira account to continue.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No issues found.</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Try a different search or account.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CommandEmpty>
|
||||
|
||||
{/* Account selection - only show if we have multiple accounts */}
|
||||
{credentials.length > 1 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
Switch Account
|
||||
</div>
|
||||
{credentials.map((cred) => (
|
||||
<CommandItem
|
||||
key={cred.id}
|
||||
value={`account-${cred.id}`}
|
||||
onSelect={() => setSelectedCredentialId(cred.id)}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
<span className='font-normal'>{cred.name}</span>
|
||||
</div>
|
||||
{cred.id === selectedCredentialId && (
|
||||
<Check className='ml-auto h-4 w-4' />
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Issues list */}
|
||||
{issues.length > 0 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
Issues
|
||||
</div>
|
||||
{issues.map((issue) => (
|
||||
<CommandItem
|
||||
key={issue.id}
|
||||
value={`issue-${issue.id}-${issue.name}`}
|
||||
onSelect={() => handleSelectIssue(issue)}
|
||||
>
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
<span className='truncate font-normal'>{issue.name}</span>
|
||||
</div>
|
||||
{issue.id === selectedIssueId && <Check className='ml-auto h-4 w-4' />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Connect account option - only show if no credentials */}
|
||||
{credentials.length === 0 && (
|
||||
<CommandGroup>
|
||||
<CommandItem onSelect={handleAddCredential}>
|
||||
<div className='flex items-center gap-2 text-foreground'>
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
<span>Connect Jira account</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
)}
|
||||
</Popover>
|
||||
|
||||
{showPreview && selectedIssue && (
|
||||
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
|
||||
<div className='absolute top-2 right-2'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-5 w-5 hover:bg-muted'
|
||||
onClick={handleClearSelection}
|
||||
>
|
||||
<X className='h-3 w-3' />
|
||||
</Button>
|
||||
</div>
|
||||
<div className='flex items-center gap-3 pr-4'>
|
||||
<div className='flex h-6 w-6 flex-shrink-0 items-center justify-center rounded bg-muted/20'>
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
</div>
|
||||
<div className='min-w-0 flex-1 overflow-hidden'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<h4 className='truncate font-medium text-xs'>{selectedIssue.name}</h4>
|
||||
{selectedIssue.modifiedTime && (
|
||||
<span className='whitespace-nowrap text-muted-foreground text-xs'>
|
||||
{new Date(selectedIssue.modifiedTime).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{selectedIssue.webViewLink && (
|
||||
<a
|
||||
href={selectedIssue.webViewLink}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='flex items-center gap-1 text-foreground text-xs hover:underline'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span>Open in Jira</span>
|
||||
<ExternalLink className='h-3 w-3' />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showOAuthModal && (
|
||||
<OAuthRequiredModal
|
||||
isOpen={showOAuthModal}
|
||||
onClose={() => setShowOAuthModal(false)}
|
||||
provider={provider}
|
||||
toolName='Jira'
|
||||
requiredScopes={requiredScopes}
|
||||
serviceId={getServiceId()}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,961 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Check, ChevronDown, ExternalLink, RefreshCw, X } from 'lucide-react'
|
||||
import { MicrosoftTeamsIcon } from '@/components/icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
type Credential,
|
||||
getProviderIdFromServiceId,
|
||||
getServiceIdFromScopes,
|
||||
type OAuthProvider,
|
||||
} from '@/lib/oauth'
|
||||
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
|
||||
import { useDisplayNamesStore } from '@/stores/display-names/store'
|
||||
|
||||
const logger = createLogger('TeamsMessageSelector')
|
||||
|
||||
export interface TeamsMessageInfo {
|
||||
id: string
|
||||
displayName: string
|
||||
type: 'team' | 'channel' | 'chat'
|
||||
teamId?: string
|
||||
channelId?: string
|
||||
chatId?: string
|
||||
webViewLink?: string
|
||||
}
|
||||
|
||||
interface TeamsMessageSelectorProps {
|
||||
value: string
|
||||
onChange: (value: string, messageInfo?: TeamsMessageInfo) => void
|
||||
provider: OAuthProvider
|
||||
requiredScopes?: string[]
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
serviceId?: string
|
||||
showPreview?: boolean
|
||||
onMessageInfoChange?: (messageInfo: TeamsMessageInfo | null) => void
|
||||
credential: string
|
||||
selectionType?: 'team' | 'channel' | 'chat'
|
||||
initialTeamId?: string
|
||||
workflowId: string
|
||||
isForeignCredential?: boolean
|
||||
}
|
||||
|
||||
export function TeamsMessageSelector({
|
||||
value,
|
||||
onChange,
|
||||
provider,
|
||||
requiredScopes = [],
|
||||
label = 'Select Teams message location',
|
||||
disabled = false,
|
||||
serviceId,
|
||||
showPreview = true,
|
||||
onMessageInfoChange,
|
||||
credential,
|
||||
selectionType = 'team',
|
||||
initialTeamId,
|
||||
workflowId,
|
||||
isForeignCredential = false,
|
||||
}: TeamsMessageSelectorProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [credentials, setCredentials] = useState<Credential[]>([])
|
||||
const [teams, setTeams] = useState<TeamsMessageInfo[]>([])
|
||||
const [channels, setChannels] = useState<TeamsMessageInfo[]>([])
|
||||
const [chats, setChats] = useState<TeamsMessageInfo[]>([])
|
||||
const [selectedCredentialId, setSelectedCredentialId] = useState<string>(credential || '')
|
||||
const [selectedTeamId, setSelectedTeamId] = useState<string>('')
|
||||
const [selectedChannelId, setSelectedChannelId] = useState<string>('')
|
||||
const [selectedChatId, setSelectedChatId] = useState<string>('')
|
||||
const [selectedMessageId, setSelectedMessageId] = useState(value)
|
||||
const [selectedMessage, setSelectedMessage] = useState<TeamsMessageInfo | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [showOAuthModal, setShowOAuthModal] = useState(false)
|
||||
const initialFetchRef = useRef(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [selectionStage, setSelectionStage] = useState<'team' | 'channel' | 'chat'>(selectionType)
|
||||
const lastRestoredValueRef = useRef<string | null>(null)
|
||||
|
||||
// Get cached display name
|
||||
const cachedMessageName = useDisplayNamesStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (!credential || !value) return null
|
||||
return state.cache.files[credential]?.[value] || null
|
||||
},
|
||||
[credential, value]
|
||||
)
|
||||
)
|
||||
|
||||
// Determine the appropriate service ID based on provider and scopes
|
||||
const getServiceId = (): string => {
|
||||
if (serviceId) return serviceId
|
||||
return getServiceIdFromScopes(provider, requiredScopes)
|
||||
}
|
||||
|
||||
// Determine the appropriate provider ID based on service and scopes
|
||||
const getProviderId = (): string => {
|
||||
const effectiveServiceId = getServiceId()
|
||||
return getProviderIdFromServiceId(effectiveServiceId)
|
||||
}
|
||||
|
||||
const fetchCredentials = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const providerId = getProviderId()
|
||||
const response = await fetch(`/api/auth/oauth/credentials?provider=${providerId}`)
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setCredentials(data.credentials)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching credentials:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [provider, getProviderId, selectedCredentialId])
|
||||
|
||||
// Fetch teams
|
||||
const fetchTeams = useCallback(async () => {
|
||||
if (!selectedCredentialId) return
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/tools/microsoft-teams/teams', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
credential: selectedCredentialId,
|
||||
workflowId,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
|
||||
// If server indicates auth is required, show the auth modal
|
||||
if (response.status === 401 && errorData.authRequired) {
|
||||
logger.warn('Authentication required for Microsoft Teams')
|
||||
setShowOAuthModal(true)
|
||||
throw new Error('Microsoft Teams authentication required')
|
||||
}
|
||||
|
||||
throw new Error(errorData.error || 'Failed to fetch teams')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const teamsData = data.teams.map((team: { id: string; displayName: string }) => ({
|
||||
id: team.id,
|
||||
displayName: team.displayName,
|
||||
type: 'team' as const,
|
||||
teamId: team.id,
|
||||
webViewLink: `https://teams.microsoft.com/l/team/${team.id}`,
|
||||
}))
|
||||
|
||||
setTeams(teamsData)
|
||||
|
||||
// Cache team names in display names store
|
||||
if (selectedCredentialId && teamsData.length > 0) {
|
||||
const teamMap = teamsData.reduce((acc: Record<string, string>, team: TeamsMessageInfo) => {
|
||||
acc[team.id] = team.displayName
|
||||
return acc
|
||||
}, {})
|
||||
useDisplayNamesStore.getState().setDisplayNames('files', selectedCredentialId, teamMap)
|
||||
}
|
||||
|
||||
// If we have a selected team ID, find it in the list
|
||||
if (selectedTeamId) {
|
||||
const team = teamsData.find((t: TeamsMessageInfo) => t.teamId === selectedTeamId)
|
||||
if (team) {
|
||||
setSelectedMessage(team)
|
||||
onMessageInfoChange?.(team)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching teams:', error)
|
||||
setError((error as Error).message)
|
||||
setTeams([])
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [selectedCredentialId, selectedTeamId, onMessageInfoChange, workflowId])
|
||||
|
||||
// Fetch channels for a selected team
|
||||
const fetchChannels = useCallback(
|
||||
async (teamId: string) => {
|
||||
if (!selectedCredentialId || !teamId) return
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/tools/microsoft-teams/channels', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
credential: selectedCredentialId,
|
||||
teamId,
|
||||
workflowId,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
|
||||
// If server indicates auth is required, show the auth modal
|
||||
if (response.status === 401 && errorData.authRequired) {
|
||||
logger.warn('Authentication required for Microsoft Teams')
|
||||
setShowOAuthModal(true)
|
||||
throw new Error('Microsoft Teams authentication required')
|
||||
}
|
||||
|
||||
throw new Error(errorData.error || 'Failed to fetch channels')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const channelsData = data.channels.map((channel: { id: string; displayName: string }) => ({
|
||||
id: `${teamId}-${channel.id}`,
|
||||
displayName: channel.displayName,
|
||||
type: 'channel' as const,
|
||||
teamId,
|
||||
channelId: channel.id,
|
||||
webViewLink: `https://teams.microsoft.com/l/channel/${teamId}/${encodeURIComponent(channel.displayName)}/${channel.id}`,
|
||||
}))
|
||||
|
||||
setChannels(channelsData)
|
||||
|
||||
// Cache channel names in display names store
|
||||
if (selectedCredentialId && channelsData.length > 0) {
|
||||
const channelMap = channelsData.reduce(
|
||||
(acc: Record<string, string>, channel: TeamsMessageInfo) => {
|
||||
acc[channel.channelId!] = channel.displayName
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
useDisplayNamesStore.getState().setDisplayNames('files', selectedCredentialId, channelMap)
|
||||
}
|
||||
|
||||
// If we have a selected channel ID, find it in the list
|
||||
if (selectedChannelId) {
|
||||
const channel = channelsData.find(
|
||||
(c: TeamsMessageInfo) => c.channelId === selectedChannelId
|
||||
)
|
||||
if (channel) {
|
||||
setSelectedMessage(channel)
|
||||
onMessageInfoChange?.(channel)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching channels:', error)
|
||||
setError((error as Error).message)
|
||||
setChannels([])
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
},
|
||||
[selectedCredentialId, selectedChannelId, onMessageInfoChange, workflowId]
|
||||
)
|
||||
|
||||
// Fetch chats
|
||||
const fetchChats = useCallback(async () => {
|
||||
if (!selectedCredentialId) return
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/tools/microsoft-teams/chats', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
credential: selectedCredentialId,
|
||||
workflowId: workflowId, // Pass the workflowId for server-side authentication
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
|
||||
// If server indicates auth is required, show the auth modal
|
||||
if (response.status === 401 && errorData.authRequired) {
|
||||
logger.warn('Authentication required for Microsoft Teams')
|
||||
setShowOAuthModal(true)
|
||||
throw new Error('Microsoft Teams authentication required')
|
||||
}
|
||||
|
||||
throw new Error(errorData.error || 'Failed to fetch chats')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const chatsData = data.chats.map((chat: { id: string; displayName: string }) => ({
|
||||
id: chat.id,
|
||||
displayName: chat.displayName,
|
||||
type: 'chat' as const,
|
||||
chatId: chat.id,
|
||||
webViewLink: `https://teams.microsoft.com/l/chat/${chat.id}`,
|
||||
}))
|
||||
|
||||
setChats(chatsData)
|
||||
|
||||
if (selectedCredentialId && chatsData.length > 0) {
|
||||
const chatMap = chatsData.reduce((acc: Record<string, string>, chat: TeamsMessageInfo) => {
|
||||
acc[chat.id] = chat.displayName
|
||||
return acc
|
||||
}, {})
|
||||
useDisplayNamesStore.getState().setDisplayNames('files', selectedCredentialId, chatMap)
|
||||
}
|
||||
|
||||
// If we have a selected chat ID, find it in the list
|
||||
if (selectedChatId) {
|
||||
const chat = chatsData.find((c: TeamsMessageInfo) => c.chatId === selectedChatId)
|
||||
if (chat) {
|
||||
setSelectedMessage(chat)
|
||||
onMessageInfoChange?.(chat)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching chats:', error)
|
||||
setError((error as Error).message)
|
||||
setChats([])
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [selectedCredentialId, selectedChatId, onMessageInfoChange, workflowId])
|
||||
|
||||
// Update selection stage based on selected values and selectionType
|
||||
useEffect(() => {
|
||||
// If we have explicit values selected, use those to determine the stage
|
||||
if (selectedChatId) {
|
||||
setSelectionStage('chat')
|
||||
} else if (selectedChannelId) {
|
||||
setSelectionStage('channel')
|
||||
} else if (selectionType === 'channel' && selectedTeamId) {
|
||||
// If we're in channel mode and have a team selected, go to channel selection
|
||||
setSelectionStage('channel')
|
||||
} else if (selectionType !== 'team' && !selectedTeamId) {
|
||||
// If no selections but we have a specific selection type, use that
|
||||
// But for channel selection, start with team selection if no team is selected
|
||||
if (selectionType === 'channel') {
|
||||
setSelectionStage('team')
|
||||
} else {
|
||||
setSelectionStage(selectionType)
|
||||
}
|
||||
} else {
|
||||
// Default to team selection
|
||||
setSelectionStage('team')
|
||||
}
|
||||
}, [selectedTeamId, selectedChannelId, selectedChatId, selectionType])
|
||||
|
||||
// Handle open change
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
if (disabled || isForeignCredential) {
|
||||
setOpen(false)
|
||||
return
|
||||
}
|
||||
setOpen(isOpen)
|
||||
// Only fetch data when opening the dropdown
|
||||
if (isOpen && selectedCredentialId) {
|
||||
if (selectionStage === 'team') {
|
||||
fetchTeams()
|
||||
} else if (selectionStage === 'channel' && selectedTeamId) {
|
||||
fetchChannels(selectedTeamId)
|
||||
} else if (selectionStage === 'chat') {
|
||||
fetchChats()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keep internal selectedMessageId in sync with the value prop
|
||||
useEffect(() => {
|
||||
if (value !== selectedMessageId) {
|
||||
setSelectedMessageId(value)
|
||||
}
|
||||
}, [value])
|
||||
|
||||
// Handle team selection
|
||||
const handleSelectTeam = (team: TeamsMessageInfo) => {
|
||||
setSelectedTeamId(team.teamId || '')
|
||||
setSelectedChannelId('')
|
||||
setSelectedChatId('')
|
||||
setSelectedMessage(team)
|
||||
setSelectedMessageId(team.id)
|
||||
onChange(team.id, team)
|
||||
onMessageInfoChange?.(team)
|
||||
setSelectionStage('channel')
|
||||
fetchChannels(team.teamId || '')
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
// Handle channel selection
|
||||
const handleSelectChannel = (channel: TeamsMessageInfo) => {
|
||||
setSelectedChannelId(channel.channelId || '')
|
||||
setSelectedChatId('')
|
||||
setSelectedMessage(channel)
|
||||
setSelectedMessageId(channel.channelId || '')
|
||||
onChange(channel.channelId || '', channel)
|
||||
onMessageInfoChange?.(channel)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
// Handle chat selection
|
||||
const handleSelectChat = (chat: TeamsMessageInfo) => {
|
||||
setSelectedChatId(chat.chatId || '')
|
||||
setSelectedMessage(chat)
|
||||
setSelectedMessageId(chat.id)
|
||||
onChange(chat.id, chat)
|
||||
onMessageInfoChange?.(chat)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
// Handle adding a new credential
|
||||
const handleAddCredential = () => {
|
||||
// Show the OAuth modal
|
||||
setShowOAuthModal(true)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
// Clear selection
|
||||
const handleClearSelection = () => {
|
||||
setSelectedMessageId('')
|
||||
setSelectedTeamId('')
|
||||
setSelectedChannelId('')
|
||||
setSelectedChatId('')
|
||||
setSelectedMessage(null)
|
||||
setError(null)
|
||||
onChange('', undefined)
|
||||
onMessageInfoChange?.(null)
|
||||
setSelectionStage(selectionType) // Reset to the initial selection type
|
||||
}
|
||||
|
||||
// Render dropdown options based on the current selection stage
|
||||
const renderSelectionOptions = () => {
|
||||
if (selectionStage === 'team' && teams.length > 0) {
|
||||
return (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>Teams</div>
|
||||
{teams.map((team) => (
|
||||
<CommandItem
|
||||
key={team.id}
|
||||
value={`team-${team.id}-${team.displayName}`}
|
||||
onSelect={() => handleSelectTeam(team)}
|
||||
>
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
<MicrosoftTeamsIcon className='h-4 w-4' />
|
||||
<span className='truncate font-normal'>{team.displayName}</span>
|
||||
</div>
|
||||
{team.teamId === selectedTeamId && <Check className='ml-auto h-4 w-4' />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)
|
||||
}
|
||||
|
||||
if (selectionStage === 'channel' && channels.length > 0) {
|
||||
return (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>Channels</div>
|
||||
{channels.map((channel) => (
|
||||
<CommandItem
|
||||
key={channel.id}
|
||||
value={`channel-${channel.id}-${channel.displayName}`}
|
||||
onSelect={() => handleSelectChannel(channel)}
|
||||
>
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
<MicrosoftTeamsIcon className='h-4 w-4' />
|
||||
<span className='truncate font-normal'>{channel.displayName}</span>
|
||||
</div>
|
||||
{channel.channelId === selectedChannelId && <Check className='ml-auto h-4 w-4' />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)
|
||||
}
|
||||
|
||||
if (selectionStage === 'chat' && chats.length > 0) {
|
||||
return (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>Chats</div>
|
||||
{chats.map((chat) => (
|
||||
<CommandItem
|
||||
key={chat.id}
|
||||
value={`chat-${chat.id}-${chat.displayName}`}
|
||||
onSelect={() => handleSelectChat(chat)}
|
||||
>
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
<MicrosoftTeamsIcon className='h-4 w-4' />
|
||||
<span className='truncate font-normal'>{chat.displayName}</span>
|
||||
</div>
|
||||
{chat.chatId === selectedChatId && <Check className='ml-auto h-4 w-4' />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Restore team selection on page refresh
|
||||
const restoreTeamSelection = useCallback(
|
||||
async (teamId: string) => {
|
||||
if (!selectedCredentialId || !teamId || selectionType !== 'team') return
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const response = await fetch('/api/tools/microsoft-teams/teams', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ credential: selectedCredentialId, workflowId }),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
const team = data.teams.find((t: { id: string; displayName: string }) => t.id === teamId)
|
||||
if (team) {
|
||||
const teamInfo: TeamsMessageInfo = {
|
||||
id: team.id,
|
||||
displayName: team.displayName,
|
||||
type: 'team',
|
||||
teamId: team.id,
|
||||
webViewLink: `https://teams.microsoft.com/l/team/${team.id}`,
|
||||
}
|
||||
setSelectedTeamId(team.id)
|
||||
setSelectedMessage(teamInfo)
|
||||
onMessageInfoChange?.(teamInfo)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error restoring team selection:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
},
|
||||
[selectedCredentialId, selectionType, onMessageInfoChange, workflowId]
|
||||
)
|
||||
|
||||
// Restore chat selection on page refresh
|
||||
const restoreChatSelection = useCallback(
|
||||
async (chatId: string) => {
|
||||
if (!selectedCredentialId || !chatId || selectionType !== 'chat') return
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const response = await fetch('/api/tools/microsoft-teams/chats', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ credential: selectedCredentialId, workflowId }),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
|
||||
// Cache all chat names
|
||||
if (data.chats && selectedCredentialId) {
|
||||
const chatMap = data.chats.reduce(
|
||||
(acc: Record<string, string>, c: { id: string; displayName: string }) => {
|
||||
acc[c.id] = c.displayName
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
useDisplayNamesStore.getState().setDisplayNames('files', selectedCredentialId, chatMap)
|
||||
}
|
||||
|
||||
const chat = data.chats.find((c: { id: string; displayName: string }) => c.id === chatId)
|
||||
if (chat) {
|
||||
const chatInfo: TeamsMessageInfo = {
|
||||
id: chat.id,
|
||||
displayName: chat.displayName,
|
||||
type: 'chat',
|
||||
chatId: chat.id,
|
||||
webViewLink: `https://teams.microsoft.com/l/chat/${chat.id}`,
|
||||
}
|
||||
setSelectedChatId(chat.id)
|
||||
setSelectedMessage(chatInfo)
|
||||
onMessageInfoChange?.(chatInfo)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error restoring chat selection:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
},
|
||||
[selectedCredentialId, selectionType, onMessageInfoChange, workflowId]
|
||||
)
|
||||
|
||||
// Restore channel selection on page refresh
|
||||
const restoreChannelSelection = useCallback(
|
||||
async (channelId: string) => {
|
||||
if (!selectedCredentialId || !channelId || selectionType !== 'channel') return
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
// First fetch teams to search through them
|
||||
const teamsResponse = await fetch('/api/tools/microsoft-teams/teams', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ credential: selectedCredentialId, workflowId }),
|
||||
})
|
||||
|
||||
if (teamsResponse.ok) {
|
||||
const teamsData = await teamsResponse.json()
|
||||
|
||||
// Create parallel promises for all teams to search for the channel
|
||||
const channelSearchPromises = teamsData.teams.map(
|
||||
async (team: { id: string; displayName: string }) => {
|
||||
try {
|
||||
const channelsResponse = await fetch('/api/tools/microsoft-teams/channels', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
credential: selectedCredentialId,
|
||||
teamId: team.id,
|
||||
workflowId,
|
||||
}),
|
||||
})
|
||||
|
||||
if (channelsResponse.ok) {
|
||||
const channelsData = await channelsResponse.json()
|
||||
const channel = channelsData.channels.find(
|
||||
(c: { id: string; displayName: string }) => c.id === channelId
|
||||
)
|
||||
if (channel) {
|
||||
return {
|
||||
team,
|
||||
channel,
|
||||
channelInfo: {
|
||||
id: `${team.id}-${channel.id}`,
|
||||
displayName: channel.displayName,
|
||||
type: 'channel' as const,
|
||||
teamId: team.id,
|
||||
channelId: channel.id,
|
||||
webViewLink: `https://teams.microsoft.com/l/channel/${team.id}/${encodeURIComponent(channel.displayName)}/${channel.id}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`Error searching for channel in team ${team.id}:`,
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
)
|
||||
|
||||
// Wait for all parallel requests to complete (or fail)
|
||||
const results = await Promise.allSettled(channelSearchPromises)
|
||||
|
||||
// Find the first successful result that contains our channel
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled' && result.value) {
|
||||
const { channelInfo } = result.value
|
||||
setSelectedTeamId(channelInfo.teamId!)
|
||||
setSelectedChannelId(channelInfo.channelId!)
|
||||
setSelectedMessage(channelInfo)
|
||||
onMessageInfoChange?.(channelInfo)
|
||||
return // Found the channel, exit successfully
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here, the channel wasn't found in any team
|
||||
logger.warn(`Channel ${channelId} not found in any accessible team`)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error restoring channel selection:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
},
|
||||
[selectedCredentialId, selectionType, onMessageInfoChange, workflowId]
|
||||
)
|
||||
|
||||
// Set initial team ID if provided
|
||||
useEffect(() => {
|
||||
if (initialTeamId && !selectedTeamId && selectionType === 'channel') {
|
||||
setSelectedTeamId(initialTeamId)
|
||||
}
|
||||
}, [initialTeamId, selectedTeamId, selectionType])
|
||||
|
||||
// Clear selection when selectionType changes to allow proper restoration
|
||||
useEffect(() => {
|
||||
setSelectedMessage(null)
|
||||
setSelectedTeamId('')
|
||||
setSelectedChannelId('')
|
||||
setSelectedChatId('')
|
||||
}, [selectionType])
|
||||
|
||||
// Fetch appropriate data on initial mount based on selectionType
|
||||
useEffect(() => {
|
||||
if (!initialFetchRef.current) {
|
||||
fetchCredentials()
|
||||
initialFetchRef.current = true
|
||||
}
|
||||
}, [fetchCredentials])
|
||||
|
||||
// Keep local credential state in sync with persisted credential
|
||||
useEffect(() => {
|
||||
if (credential && credential !== selectedCredentialId) {
|
||||
setSelectedCredentialId(credential)
|
||||
}
|
||||
}, [credential, selectedCredentialId])
|
||||
|
||||
// Restore selection whenever the canonical value changes
|
||||
useEffect(() => {
|
||||
if (value && selectedCredentialId) {
|
||||
// Only restore if we haven't already restored this value
|
||||
if (lastRestoredValueRef.current !== value) {
|
||||
lastRestoredValueRef.current = value
|
||||
|
||||
if (selectionType === 'team') {
|
||||
restoreTeamSelection(value)
|
||||
} else if (selectionType === 'chat') {
|
||||
restoreChatSelection(value)
|
||||
} else if (selectionType === 'channel') {
|
||||
restoreChannelSelection(value)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
lastRestoredValueRef.current = null
|
||||
setSelectedMessage(null)
|
||||
}
|
||||
}, [
|
||||
value,
|
||||
selectedCredentialId,
|
||||
selectionType,
|
||||
restoreTeamSelection,
|
||||
restoreChatSelection,
|
||||
restoreChannelSelection,
|
||||
])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-2'>
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
role='combobox'
|
||||
aria-expanded={open}
|
||||
className='h-10 w-full min-w-0 justify-between'
|
||||
disabled={disabled || isForeignCredential}
|
||||
>
|
||||
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
|
||||
{cachedMessageName ? (
|
||||
<>
|
||||
<MicrosoftTeamsIcon className='h-4 w-4' />
|
||||
<span className='truncate font-normal'>{cachedMessageName}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MicrosoftTeamsIcon className='h-4 w-4' />
|
||||
<span className='truncate text-muted-foreground'>
|
||||
{selectionType === 'channel' && selectionStage === 'team'
|
||||
? 'Select a team first'
|
||||
: label}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
{!isForeignCredential && (
|
||||
<PopoverContent className='w-[300px] p-0' align='start'>
|
||||
{/* Current account indicator */}
|
||||
{selectedCredentialId && credentials.length > 0 && (
|
||||
<div className='flex items-center justify-between border-b px-3 py-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<MicrosoftTeamsIcon className='h-4 w-4' />
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
|
||||
'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
{credentials.length > 1 && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 px-2 text-xs'
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Switch
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Command>
|
||||
<CommandInput placeholder={`Search ${selectionStage}s...`} />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{isLoading ? (
|
||||
<div className='flex items-center justify-center p-4'>
|
||||
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||
<span className='ml-2'>Loading {selectionStage}s...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='text-destructive text-sm'>{error}</p>
|
||||
{selectionStage === 'chat' && error.includes('teams') && (
|
||||
<p className='mt-1 text-muted-foreground text-xs'>
|
||||
There was an issue fetching chats. Please try again or connect a
|
||||
different account.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : credentials.length === 0 ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No accounts connected.</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Connect a Microsoft Teams account to{' '}
|
||||
{selectionStage === 'chat'
|
||||
? 'access your chats'
|
||||
: selectionStage === 'channel'
|
||||
? 'see your channels'
|
||||
: 'continue'}
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No {selectionStage}s found.</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{selectionStage === 'team'
|
||||
? 'Try a different account.'
|
||||
: selectionStage === 'channel'
|
||||
? selectedTeamId
|
||||
? 'This team has no channels or you may not have access.'
|
||||
: 'Please select a team first to see its channels.'
|
||||
: 'Try a different account or check if you have any active chats.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CommandEmpty>
|
||||
|
||||
{/* Account selection - only show if we have multiple accounts */}
|
||||
{credentials.length > 1 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
Switch Account
|
||||
</div>
|
||||
{credentials.map((cred) => (
|
||||
<CommandItem
|
||||
key={cred.id}
|
||||
value={`account-${cred.id}`}
|
||||
onSelect={() => {
|
||||
setSelectedCredentialId(cred.id)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<MicrosoftTeamsIcon className='h-4 w-4' />
|
||||
<span className='font-normal'>{cred.name}</span>
|
||||
</div>
|
||||
{cred.id === selectedCredentialId && (
|
||||
<Check className='ml-auto h-4 w-4' />
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Display appropriate options based on selection stage */}
|
||||
{renderSelectionOptions()}
|
||||
|
||||
{/* Connect account option - only show if no credentials */}
|
||||
{credentials.length === 0 && (
|
||||
<CommandGroup>
|
||||
<CommandItem onSelect={handleAddCredential}>
|
||||
<div className='flex items-center gap-2 text-foreground'>
|
||||
<MicrosoftTeamsIcon className='h-4 w-4' />
|
||||
<span>Connect Microsoft Teams account</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
)}
|
||||
</Popover>
|
||||
|
||||
{/* Selection preview */}
|
||||
{showPreview && selectedMessage && (
|
||||
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
|
||||
<div className='absolute top-2 right-2'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-5 w-5 hover:bg-muted'
|
||||
onClick={handleClearSelection}
|
||||
>
|
||||
<X className='h-3 w-3' />
|
||||
</Button>
|
||||
</div>
|
||||
<div className='flex items-center gap-3 pr-4'>
|
||||
<div className='flex h-6 w-6 flex-shrink-0 items-center justify-center rounded bg-muted/20'>
|
||||
<MicrosoftTeamsIcon className='h-4 w-4' />
|
||||
</div>
|
||||
<div className='min-w-0 flex-1 overflow-hidden'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<h4 className='truncate font-medium text-xs'>{selectedMessage.displayName}</h4>
|
||||
<span className='whitespace-nowrap text-muted-foreground text-xs'>
|
||||
{selectedMessage.type}
|
||||
</span>
|
||||
</div>
|
||||
{selectedMessage.webViewLink ? (
|
||||
<a
|
||||
href={selectedMessage.webViewLink}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='flex items-center gap-1 text-foreground text-xs hover:underline'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span>Open in Microsoft Teams</span>
|
||||
<ExternalLink className='h-3 w-3' />
|
||||
</a>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showOAuthModal && (
|
||||
<OAuthRequiredModal
|
||||
isOpen={showOAuthModal}
|
||||
onClose={() => setShowOAuthModal(false)}
|
||||
provider={provider}
|
||||
toolName='Microsoft Teams'
|
||||
requiredScopes={requiredScopes}
|
||||
serviceId={getServiceId()}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,484 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Check, ChevronDown, X } from 'lucide-react'
|
||||
import { WealthboxIcon } from '@/components/icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
type Credential,
|
||||
getProviderIdFromServiceId,
|
||||
getServiceIdFromScopes,
|
||||
type OAuthProvider,
|
||||
} from '@/lib/oauth'
|
||||
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
|
||||
import { useDisplayNamesStore } from '@/stores/display-names/store'
|
||||
|
||||
const logger = createLogger('WealthboxFileSelector')
|
||||
|
||||
export interface WealthboxItemInfo {
|
||||
id: string
|
||||
name: string
|
||||
type: 'contact'
|
||||
content?: string
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
interface WealthboxFileSelectorProps {
|
||||
value: string
|
||||
onChange: (value: string, itemInfo?: WealthboxItemInfo) => void
|
||||
provider: OAuthProvider
|
||||
requiredScopes?: string[]
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
serviceId?: string
|
||||
showPreview?: boolean
|
||||
onFileInfoChange?: (itemInfo: WealthboxItemInfo | null) => void
|
||||
itemType?: 'contact'
|
||||
credentialId?: string
|
||||
}
|
||||
|
||||
export function WealthboxFileSelector({
|
||||
value,
|
||||
onChange,
|
||||
provider,
|
||||
requiredScopes = [],
|
||||
label = 'Select item',
|
||||
disabled = false,
|
||||
serviceId,
|
||||
showPreview = true,
|
||||
onFileInfoChange,
|
||||
itemType = 'contact',
|
||||
credentialId,
|
||||
}: WealthboxFileSelectorProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [credentials, setCredentials] = useState<Credential[]>([])
|
||||
const [selectedCredentialId, setSelectedCredentialId] = useState<string>(credentialId || '')
|
||||
const [selectedItemId, setSelectedItemId] = useState(value)
|
||||
const [selectedItem, setSelectedItem] = useState<WealthboxItemInfo | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isLoadingSelectedItem, setIsLoadingSelectedItem] = useState(false)
|
||||
const [isLoadingItems, setIsLoadingItems] = useState(false)
|
||||
const [availableItems, setAvailableItems] = useState<WealthboxItemInfo[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState<string>('')
|
||||
const [showOAuthModal, setShowOAuthModal] = useState(false)
|
||||
const [credentialsLoaded, setCredentialsLoaded] = useState(false)
|
||||
const initialFetchRef = useRef(false)
|
||||
|
||||
// Get cached display name
|
||||
const cachedItemName = useDisplayNamesStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
const effectiveCredentialId = credentialId || selectedCredentialId
|
||||
if (!effectiveCredentialId || !value) return null
|
||||
return state.cache.files[effectiveCredentialId]?.[value] || null
|
||||
},
|
||||
[credentialId, selectedCredentialId, value]
|
||||
)
|
||||
)
|
||||
|
||||
// Determine the appropriate service ID based on provider and scopes
|
||||
const getServiceId = (): string => {
|
||||
if (serviceId) return serviceId
|
||||
return getServiceIdFromScopes(provider, requiredScopes)
|
||||
}
|
||||
|
||||
// Determine the appropriate provider ID based on service and scopes
|
||||
const getProviderId = (): string => {
|
||||
const effectiveServiceId = getServiceId()
|
||||
return getProviderIdFromServiceId(effectiveServiceId)
|
||||
}
|
||||
|
||||
// Fetch available credentials for this provider
|
||||
const fetchCredentials = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
setCredentialsLoaded(false)
|
||||
try {
|
||||
const providerId = getProviderId()
|
||||
const response = await fetch(`/api/auth/oauth/credentials?provider=${providerId}`)
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setCredentials(data.credentials)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching credentials:', { error })
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
setCredentialsLoaded(true)
|
||||
}
|
||||
}, [provider, getProviderId, selectedCredentialId])
|
||||
|
||||
// Keep local credential state in sync with persisted credential
|
||||
useEffect(() => {
|
||||
if (credentialId && credentialId !== selectedCredentialId) {
|
||||
setSelectedCredentialId(credentialId)
|
||||
}
|
||||
}, [credentialId, selectedCredentialId])
|
||||
|
||||
// Debounced search function
|
||||
const [searchTimeout, setSearchTimeout] = useState<NodeJS.Timeout | null>(null)
|
||||
|
||||
// Fetch available items for the selected credential
|
||||
const fetchAvailableItems = useCallback(async () => {
|
||||
if (!selectedCredentialId) return
|
||||
|
||||
setIsLoadingItems(true)
|
||||
try {
|
||||
const queryParams = new URLSearchParams({
|
||||
credentialId: selectedCredentialId,
|
||||
type: itemType,
|
||||
})
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
queryParams.append('query', searchQuery.trim())
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/auth/oauth/wealthbox/items?${queryParams.toString()}`)
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setAvailableItems(data.items || [])
|
||||
|
||||
// Cache item names in display names store
|
||||
if (selectedCredentialId && data.items) {
|
||||
const itemMap = data.items.reduce(
|
||||
(acc: Record<string, string>, item: WealthboxItemInfo) => {
|
||||
acc[item.id] = item.name
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
useDisplayNamesStore.getState().setDisplayNames('files', selectedCredentialId, itemMap)
|
||||
}
|
||||
} else {
|
||||
logger.error('Error fetching available items:', {
|
||||
error: await response.text(),
|
||||
})
|
||||
setAvailableItems([])
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching available items:', { error })
|
||||
setAvailableItems([])
|
||||
} finally {
|
||||
setIsLoadingItems(false)
|
||||
}
|
||||
}, [selectedCredentialId, searchQuery, itemType])
|
||||
|
||||
// Fetch a single item by ID
|
||||
const fetchItemById = useCallback(
|
||||
async (itemId: string) => {
|
||||
if (!selectedCredentialId || !itemId) return null
|
||||
|
||||
setIsLoadingSelectedItem(true)
|
||||
try {
|
||||
const queryParams = new URLSearchParams({
|
||||
credentialId: selectedCredentialId,
|
||||
itemId: itemId,
|
||||
type: itemType,
|
||||
})
|
||||
|
||||
const response = await fetch(`/api/auth/oauth/wealthbox/item?${queryParams.toString()}`)
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
if (data.item) {
|
||||
setSelectedItem(data.item)
|
||||
onFileInfoChange?.(data.item)
|
||||
|
||||
// Cache the item name in display names store
|
||||
if (selectedCredentialId) {
|
||||
useDisplayNamesStore
|
||||
.getState()
|
||||
.setDisplayNames('files', selectedCredentialId, { [data.item.id]: data.item.name })
|
||||
}
|
||||
|
||||
return data.item
|
||||
}
|
||||
} else {
|
||||
const errorText = await response.text()
|
||||
logger.error('Error fetching item by ID:', { error: errorText })
|
||||
|
||||
if (response.status === 404 || response.status === 403) {
|
||||
logger.info('Item not accessible, clearing selection')
|
||||
setSelectedItemId('')
|
||||
onChange('')
|
||||
onFileInfoChange?.(null)
|
||||
}
|
||||
}
|
||||
return null
|
||||
} catch (error) {
|
||||
logger.error('Error fetching item by ID:', { error })
|
||||
return null
|
||||
} finally {
|
||||
setIsLoadingSelectedItem(false)
|
||||
}
|
||||
},
|
||||
[selectedCredentialId, itemType, onFileInfoChange, onChange]
|
||||
)
|
||||
|
||||
// Fetch credentials on initial mount
|
||||
useEffect(() => {
|
||||
if (!initialFetchRef.current) {
|
||||
fetchCredentials()
|
||||
initialFetchRef.current = true
|
||||
}
|
||||
}, [fetchCredentials])
|
||||
|
||||
// Fetch available items only when dropdown is opened
|
||||
useEffect(() => {
|
||||
if (selectedCredentialId && open) {
|
||||
fetchAvailableItems()
|
||||
}
|
||||
}, [selectedCredentialId, open, fetchAvailableItems])
|
||||
|
||||
// Fetch item info on mount if we have a value but no selectedItem state
|
||||
useEffect(() => {
|
||||
if (value && selectedCredentialId && !selectedItem) {
|
||||
fetchItemById(value)
|
||||
}
|
||||
}, [value, selectedCredentialId, selectedItem, fetchItemById])
|
||||
|
||||
// Clear selectedItem when value is cleared
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
setSelectedItem(null)
|
||||
onFileInfoChange?.(null)
|
||||
}
|
||||
}, [value, onFileInfoChange])
|
||||
|
||||
// Handle search input changes with debouncing
|
||||
const handleSearchChange = useCallback(
|
||||
(newQuery: string) => {
|
||||
setSearchQuery(newQuery)
|
||||
|
||||
// Clear existing timeout
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout)
|
||||
}
|
||||
|
||||
// Set new timeout for search
|
||||
const timeout = setTimeout(() => {
|
||||
if (selectedCredentialId) {
|
||||
fetchAvailableItems()
|
||||
}
|
||||
}, 300) // 300ms debounce
|
||||
|
||||
setSearchTimeout(timeout)
|
||||
},
|
||||
[selectedCredentialId, fetchAvailableItems, searchTimeout]
|
||||
)
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout)
|
||||
}
|
||||
}
|
||||
}, [searchTimeout])
|
||||
|
||||
// Handle selecting an item
|
||||
const handleItemSelect = (item: WealthboxItemInfo) => {
|
||||
setSelectedItemId(item.id)
|
||||
setSelectedItem(item)
|
||||
onChange(item.id, item)
|
||||
onFileInfoChange?.(item)
|
||||
setOpen(false)
|
||||
setSearchQuery('')
|
||||
}
|
||||
|
||||
// Handle adding a new credential
|
||||
const handleAddCredential = () => {
|
||||
setShowOAuthModal(true)
|
||||
setOpen(false)
|
||||
setSearchQuery('')
|
||||
}
|
||||
|
||||
// Clear selection
|
||||
const handleClearSelection = () => {
|
||||
setSelectedItemId('')
|
||||
onChange('', undefined)
|
||||
onFileInfoChange?.(null)
|
||||
}
|
||||
|
||||
const getItemTypeLabel = () => {
|
||||
switch (itemType) {
|
||||
case 'contact':
|
||||
return 'Contacts'
|
||||
default:
|
||||
return 'Contacts'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-2'>
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
setOpen(isOpen)
|
||||
if (!isOpen) {
|
||||
setSearchQuery('')
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout)
|
||||
setSearchTimeout(null)
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
role='combobox'
|
||||
aria-expanded={open}
|
||||
className='w-full justify-between'
|
||||
disabled={disabled}
|
||||
>
|
||||
{cachedItemName ? (
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
<WealthboxIcon className='h-4 w-4' />
|
||||
<span className='truncate font-normal'>{cachedItemName}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex items-center gap-2'>
|
||||
<WealthboxIcon className='h-4 w-4' />
|
||||
<span className='text-muted-foreground'>{label}</span>
|
||||
</div>
|
||||
)}
|
||||
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-[300px] p-0' align='start'>
|
||||
<Command shouldFilter={false}>
|
||||
<div className='flex items-center border-b px-3' cmdk-input-wrapper=''>
|
||||
<input
|
||||
placeholder={`Search ${itemType}s...`}
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
className='flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50'
|
||||
/>
|
||||
</div>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{isLoadingItems ? `Loading ${itemType}s...` : `No ${itemType}s found.`}
|
||||
</CommandEmpty>
|
||||
|
||||
{credentials.length > 1 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
Switch Account
|
||||
</div>
|
||||
{credentials.map((cred) => (
|
||||
<CommandItem
|
||||
key={cred.id}
|
||||
value={`account-${cred.id}`}
|
||||
onSelect={() => setSelectedCredentialId(cred.id)}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<WealthboxIcon className='h-4 w-4' />
|
||||
<span className='font-normal'>{cred.name}</span>
|
||||
</div>
|
||||
{cred.id === selectedCredentialId && <Check className='ml-auto h-4 w-4' />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{availableItems.length > 0 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
{getItemTypeLabel()}
|
||||
</div>
|
||||
{availableItems.map((item) => (
|
||||
<CommandItem
|
||||
key={item.id}
|
||||
value={`item-${item.id}-${item.name}`}
|
||||
onSelect={() => handleItemSelect(item)}
|
||||
>
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
<WealthboxIcon className='h-4 w-4' />
|
||||
<div className='min-w-0 flex-1'>
|
||||
<span className='truncate font-normal'>{item.name}</span>
|
||||
{item.updatedAt && (
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
Updated {new Date(item.updatedAt).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{item.id === selectedItemId && <Check className='ml-auto h-4 w-4' />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{credentials.length === 0 && (
|
||||
<CommandGroup>
|
||||
<CommandItem onSelect={handleAddCredential}>
|
||||
<div className='flex items-center gap-2 text-foreground'>
|
||||
<WealthboxIcon className='h-4 w-4' />
|
||||
<span>Connect Wealthbox account</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{showPreview && selectedItem && (
|
||||
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
|
||||
<div className='absolute top-2 right-2'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-5 w-5 hover:bg-muted'
|
||||
onClick={handleClearSelection}
|
||||
>
|
||||
<X className='h-3 w-3' />
|
||||
</Button>
|
||||
</div>
|
||||
<div className='flex items-center gap-3 pr-4'>
|
||||
<div className='flex h-6 w-6 flex-shrink-0 items-center justify-center rounded bg-muted/20'>
|
||||
<WealthboxIcon className='h-4 w-4' />
|
||||
</div>
|
||||
<div className='min-w-0 flex-1 overflow-hidden'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<h4 className='truncate font-medium text-xs'>{selectedItem.name}</h4>
|
||||
{selectedItem.updatedAt && (
|
||||
<span className='whitespace-nowrap text-muted-foreground text-xs'>
|
||||
{new Date(selectedItem.updatedAt).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className='text-muted-foreground text-xs capitalize'>{selectedItem.type}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showOAuthModal && (
|
||||
<OAuthRequiredModal
|
||||
isOpen={showOAuthModal}
|
||||
onClose={() => setShowOAuthModal(false)}
|
||||
toolName='Wealthbox'
|
||||
provider={provider}
|
||||
requiredScopes={requiredScopes}
|
||||
serviceId={getServiceId()}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,22 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Tooltip } from '@/components/emcn'
|
||||
import { getEnv } from '@/lib/env'
|
||||
import { getProviderIdFromServiceId } from '@/lib/oauth'
|
||||
import {
|
||||
ConfluenceFileSelector,
|
||||
GoogleCalendarSelector,
|
||||
GoogleDrivePicker,
|
||||
JiraIssueSelector,
|
||||
MicrosoftFileSelector,
|
||||
TeamsMessageSelector,
|
||||
WealthboxFileSelector,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/file-selector/components'
|
||||
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
|
||||
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-depends-on-gate'
|
||||
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-foreign-credential'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { resolveSelectorForSubBlock, type SelectorResolution } from '@/hooks/selectors/resolution'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
@@ -41,506 +33,108 @@ export function FileSelectorInput({
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
const params = useParams()
|
||||
const workflowIdFromUrl = (params?.workflowId as string) || activeWorkflowId || ''
|
||||
// Central dependsOn gating for this selector instance
|
||||
const { finalDisabled, dependsOn } = useDependsOnGate(blockId, subBlock, {
|
||||
|
||||
const { finalDisabled } = useDependsOnGate(blockId, subBlock, {
|
||||
disabled,
|
||||
isPreview,
|
||||
previewContextValues,
|
||||
})
|
||||
|
||||
// Helper to coerce various preview value shapes into a string ID
|
||||
const coerceToIdString = (val: unknown): string => {
|
||||
if (!val) return ''
|
||||
if (typeof val === 'string') return val
|
||||
if (typeof val === 'number') return String(val)
|
||||
if (typeof val === 'object') {
|
||||
const obj = val as Record<string, any>
|
||||
return (obj.id ||
|
||||
obj.fileId ||
|
||||
obj.value ||
|
||||
obj.documentId ||
|
||||
obj.spreadsheetId ||
|
||||
'') as string
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
// Use the proper hook to get the current value and setter
|
||||
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
|
||||
const [connectedCredentialFromStore] = useSubBlockValue(blockId, 'credential')
|
||||
const [domainValueFromStore] = useSubBlockValue(blockId, 'domain')
|
||||
const [projectIdValueFromStore] = useSubBlockValue(blockId, 'projectId')
|
||||
const [planIdValueFromStore] = useSubBlockValue(blockId, 'planId')
|
||||
const [teamIdValueFromStore] = useSubBlockValue(blockId, 'teamId')
|
||||
const [operationValueFromStore] = useSubBlockValue(blockId, 'operation')
|
||||
|
||||
// Use previewContextValues if provided (for tools inside agent blocks), otherwise use store values
|
||||
const connectedCredential = previewContextValues?.credential ?? connectedCredentialFromStore
|
||||
const domainValue = previewContextValues?.domain ?? domainValueFromStore
|
||||
const projectIdValue = previewContextValues?.projectId ?? projectIdValueFromStore
|
||||
const planIdValue = previewContextValues?.planId ?? planIdValueFromStore
|
||||
const teamIdValue = previewContextValues?.teamId ?? teamIdValueFromStore
|
||||
const operationValue = previewContextValues?.operation ?? operationValueFromStore
|
||||
|
||||
// Determine if the persisted credential belongs to the current viewer
|
||||
// Use service providerId where available (e.g., onedrive/sharepoint) instead of base provider ("microsoft")
|
||||
const foreignCheckProvider = subBlock.serviceId
|
||||
? getProviderIdFromServiceId(subBlock.serviceId)
|
||||
: (subBlock.provider as string) || ''
|
||||
const normalizedCredentialId = coerceToIdString(connectedCredential)
|
||||
const providerForForeignCheck = foreignCheckProvider || (subBlock.provider as string) || undefined
|
||||
const normalizedCredentialId =
|
||||
typeof connectedCredential === 'string'
|
||||
? connectedCredential
|
||||
: typeof connectedCredential === 'object' && connectedCredential !== null
|
||||
? ((connectedCredential as Record<string, any>).id ?? '')
|
||||
: ''
|
||||
|
||||
const { isForeignCredential } = useForeignCredential(
|
||||
providerForForeignCheck,
|
||||
subBlock.serviceId || subBlock.provider,
|
||||
normalizedCredentialId
|
||||
)
|
||||
|
||||
// Get provider-specific values
|
||||
const provider = subBlock.provider || 'google-drive'
|
||||
const isConfluence = provider === 'confluence'
|
||||
const isJira = provider === 'jira'
|
||||
const isMicrosoftTeams = provider === 'microsoft-teams'
|
||||
const isMicrosoftExcel = provider === 'microsoft-excel'
|
||||
const isMicrosoftWord = provider === 'microsoft-word'
|
||||
const isMicrosoftOneDrive = provider === 'microsoft' && subBlock.serviceId === 'onedrive'
|
||||
const isGoogleCalendar = subBlock.provider === 'google-calendar'
|
||||
const isWealthbox = provider === 'wealthbox'
|
||||
const isMicrosoftSharePoint = provider === 'microsoft' && subBlock.serviceId === 'sharepoint'
|
||||
const isMicrosoftPlanner = provider === 'microsoft-planner'
|
||||
const selectorResolution = useMemo<SelectorResolution | null>(() => {
|
||||
return resolveSelectorForSubBlock(subBlock, {
|
||||
workflowId: workflowIdFromUrl,
|
||||
credentialId: normalizedCredentialId,
|
||||
domain: (domainValue as string) || undefined,
|
||||
projectId: (projectIdValue as string) || undefined,
|
||||
planId: (planIdValue as string) || undefined,
|
||||
teamId: (teamIdValue as string) || undefined,
|
||||
})
|
||||
}, [
|
||||
subBlock,
|
||||
workflowIdFromUrl,
|
||||
normalizedCredentialId,
|
||||
domainValue,
|
||||
projectIdValue,
|
||||
planIdValue,
|
||||
teamIdValue,
|
||||
])
|
||||
|
||||
// For Confluence and Jira, we need the domain and credentials
|
||||
const domain =
|
||||
isConfluence || isJira
|
||||
? (isPreview && previewContextValues?.domain?.value) || (domainValue as string) || ''
|
||||
: ''
|
||||
const jiraCredential = isJira
|
||||
? (isPreview && previewContextValues?.credential?.value) ||
|
||||
(connectedCredential as string) ||
|
||||
''
|
||||
: ''
|
||||
const missingCredential = !normalizedCredentialId
|
||||
const missingDomain =
|
||||
selectorResolution?.key &&
|
||||
(selectorResolution.key === 'confluence.pages' || selectorResolution.key === 'jira.issues') &&
|
||||
!selectorResolution.context.domain
|
||||
const missingProject =
|
||||
selectorResolution?.key === 'jira.issues' &&
|
||||
subBlock.dependsOn?.includes('projectId') &&
|
||||
!selectorResolution.context.projectId
|
||||
const missingPlan =
|
||||
selectorResolution?.key === 'microsoft.planner' && !selectorResolution.context.planId
|
||||
|
||||
// Discord channel selector removed; no special values used here
|
||||
|
||||
// Use preview value when in preview mode, otherwise use store value
|
||||
const value = isPreview ? previewValue : storeValue
|
||||
|
||||
const credentialDependencySatisfied = (() => {
|
||||
if (!dependsOn.includes('credential')) return true
|
||||
if (!normalizedCredentialId || normalizedCredentialId.trim().length === 0) {
|
||||
return false
|
||||
}
|
||||
if (isForeignCredential) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})()
|
||||
|
||||
const shouldForceDisable = !credentialDependencySatisfied
|
||||
|
||||
// For Google Drive
|
||||
const clientId = getEnv('NEXT_PUBLIC_GOOGLE_CLIENT_ID') || ''
|
||||
const apiKey = getEnv('NEXT_PUBLIC_GOOGLE_API_KEY') || ''
|
||||
|
||||
// Render Google Calendar selector
|
||||
if (isGoogleCalendar) {
|
||||
const credential = (connectedCredential as string) || ''
|
||||
const disabledReason =
|
||||
finalDisabled ||
|
||||
isForeignCredential ||
|
||||
missingCredential ||
|
||||
missingDomain ||
|
||||
missingProject ||
|
||||
missingPlan ||
|
||||
!selectorResolution?.key
|
||||
|
||||
if (!selectorResolution?.key) {
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div className='w-full'>
|
||||
<GoogleCalendarSelector
|
||||
value={
|
||||
(isPreview && previewValue !== undefined
|
||||
? (previewValue as string)
|
||||
: (storeValue as string)) || ''
|
||||
}
|
||||
onChange={(val: string) => {
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, val)
|
||||
}}
|
||||
label={subBlock.placeholder || 'Select Google Calendar'}
|
||||
disabled={finalDisabled || shouldForceDisable}
|
||||
showPreview={true}
|
||||
credentialId={credential}
|
||||
workflowId={workflowIdFromUrl}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div className='w-full rounded border border-dashed p-4 text-center text-muted-foreground text-sm'>
|
||||
File selector not supported for provider: {subBlock.provider || subBlock.serviceId}
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
<p>This file selector is not implemented for {subBlock.provider || subBlock.serviceId}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)
|
||||
}
|
||||
|
||||
// Render the appropriate picker based on provider
|
||||
if (isConfluence) {
|
||||
const credential = (connectedCredential as string) || ''
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div className='w-full'>
|
||||
<ConfluenceFileSelector
|
||||
value={
|
||||
(isPreview && previewValue !== undefined
|
||||
? (previewValue as string)
|
||||
: (storeValue as string)) || ''
|
||||
}
|
||||
onChange={(val) => {
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, val)
|
||||
}}
|
||||
domain={domain}
|
||||
provider='confluence'
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
serviceId={subBlock.serviceId}
|
||||
label={subBlock.placeholder || 'Select Confluence page'}
|
||||
disabled={finalDisabled || shouldForceDisable}
|
||||
showPreview={true}
|
||||
credentialId={credential}
|
||||
workflowId={workflowIdFromUrl}
|
||||
isForeignCredential={isForeignCredential}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
if (isJira) {
|
||||
const credential = (connectedCredential as string) || ''
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div className='w-full'>
|
||||
<JiraIssueSelector
|
||||
value={
|
||||
(isPreview && previewValue !== undefined
|
||||
? (previewValue as string)
|
||||
: (storeValue as string)) || ''
|
||||
}
|
||||
onChange={(issueKey) => {
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, issueKey)
|
||||
}}
|
||||
domain={domain}
|
||||
provider='jira'
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
serviceId={subBlock.serviceId}
|
||||
label={subBlock.placeholder || 'Select Jira issue'}
|
||||
disabled={finalDisabled || shouldForceDisable}
|
||||
showPreview={true}
|
||||
credentialId={credential}
|
||||
projectId={(projectIdValue as string) || ''}
|
||||
isForeignCredential={isForeignCredential}
|
||||
workflowId={activeWorkflowId || ''}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
if (isMicrosoftExcel) {
|
||||
const credential = (connectedCredential as string) || ''
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div className='w-full'>
|
||||
<MicrosoftFileSelector
|
||||
value={coerceToIdString(
|
||||
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
|
||||
)}
|
||||
onChange={(fileId) => setStoreValue(fileId)}
|
||||
provider='microsoft-excel'
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
serviceId={subBlock.serviceId}
|
||||
label={subBlock.placeholder || 'Select Microsoft Excel file'}
|
||||
disabled={finalDisabled || shouldForceDisable}
|
||||
showPreview={true}
|
||||
workflowId={activeWorkflowId || ''}
|
||||
credentialId={credential}
|
||||
isForeignCredential={isForeignCredential}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// Microsoft Word selector
|
||||
if (isMicrosoftWord) {
|
||||
const credential = (connectedCredential as string) || ''
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div className='w-full'>
|
||||
<MicrosoftFileSelector
|
||||
value={coerceToIdString(
|
||||
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
|
||||
)}
|
||||
onChange={(fileId) => setStoreValue(fileId)}
|
||||
provider='microsoft-word'
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
serviceId={subBlock.serviceId}
|
||||
label={subBlock.placeholder || 'Select Microsoft Word document'}
|
||||
disabled={finalDisabled || shouldForceDisable}
|
||||
showPreview={true}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// Microsoft OneDrive selector
|
||||
if (isMicrosoftOneDrive) {
|
||||
const credential = (connectedCredential as string) || ''
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div className='w-full'>
|
||||
<MicrosoftFileSelector
|
||||
value={coerceToIdString(
|
||||
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
|
||||
)}
|
||||
onChange={(fileId) => setStoreValue(fileId)}
|
||||
provider='microsoft'
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
serviceId={subBlock.serviceId}
|
||||
mimeType={subBlock.mimeType}
|
||||
label={subBlock.placeholder || 'Select OneDrive folder'}
|
||||
disabled={finalDisabled || shouldForceDisable}
|
||||
showPreview={true}
|
||||
workflowId={activeWorkflowId || ''}
|
||||
credentialId={credential}
|
||||
isForeignCredential={isForeignCredential}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// Microsoft SharePoint selector
|
||||
if (isMicrosoftSharePoint) {
|
||||
const credential = (connectedCredential as string) || ''
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div className='w-full'>
|
||||
<MicrosoftFileSelector
|
||||
value={coerceToIdString(
|
||||
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
|
||||
)}
|
||||
onChange={(fileId) => setStoreValue(fileId)}
|
||||
provider='microsoft'
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
serviceId={subBlock.serviceId}
|
||||
label={subBlock.placeholder || 'Select SharePoint site'}
|
||||
disabled={finalDisabled || shouldForceDisable}
|
||||
showPreview={true}
|
||||
workflowId={activeWorkflowId || ''}
|
||||
credentialId={credential}
|
||||
isForeignCredential={isForeignCredential}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
{!credential && (
|
||||
<Tooltip.Content side='top'>
|
||||
<p>Please select SharePoint credentials first</p>
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// Microsoft Planner task selector
|
||||
if (isMicrosoftPlanner) {
|
||||
const credential = (connectedCredential as string) || ''
|
||||
const planId = (planIdValue as string) || ''
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div className='w-full'>
|
||||
<MicrosoftFileSelector
|
||||
value={coerceToIdString(
|
||||
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
|
||||
)}
|
||||
onChange={(fileId) => setStoreValue(fileId)}
|
||||
provider='microsoft-planner'
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
serviceId='microsoft-planner'
|
||||
label={subBlock.placeholder || 'Select task'}
|
||||
disabled={finalDisabled || shouldForceDisable}
|
||||
showPreview={true}
|
||||
planId={planId}
|
||||
workflowId={activeWorkflowId || ''}
|
||||
credentialId={credential}
|
||||
isForeignCredential={isForeignCredential}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
{!credential ? (
|
||||
<Tooltip.Content side='top'>
|
||||
<p>Please select Microsoft Planner credentials first</p>
|
||||
</Tooltip.Content>
|
||||
) : !planId ? (
|
||||
<Tooltip.Content side='top'>
|
||||
<p>Please enter a Plan ID first</p>
|
||||
</Tooltip.Content>
|
||||
) : null}
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// Microsoft Teams selector
|
||||
if (isMicrosoftTeams) {
|
||||
const credential = (connectedCredential as string) || ''
|
||||
|
||||
// Determine the selector type based on the subBlock ID / operation
|
||||
let selectionType: 'team' | 'channel' | 'chat' = 'team'
|
||||
if (subBlock.id === 'teamId') selectionType = 'team'
|
||||
else if (subBlock.id === 'channelId') selectionType = 'channel'
|
||||
else if (subBlock.id === 'chatId') selectionType = 'chat'
|
||||
else {
|
||||
const operation = (operationValue as string) || ''
|
||||
if (operation.includes('chat')) selectionType = 'chat'
|
||||
else if (operation.includes('channel')) selectionType = 'channel'
|
||||
}
|
||||
|
||||
const selectedTeamId = (teamIdValue as string) || ''
|
||||
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div className='w-full'>
|
||||
<TeamsMessageSelector
|
||||
value={
|
||||
(isPreview && previewValue !== undefined
|
||||
? (previewValue as string)
|
||||
: (storeValue as string)) || ''
|
||||
}
|
||||
onChange={(val) => {
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, val)
|
||||
}}
|
||||
provider='microsoft-teams'
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
serviceId={subBlock.serviceId}
|
||||
label={subBlock.placeholder || 'Select Teams message location'}
|
||||
disabled={finalDisabled || shouldForceDisable}
|
||||
showPreview={true}
|
||||
credential={credential}
|
||||
selectionType={selectionType}
|
||||
initialTeamId={selectedTeamId}
|
||||
workflowId={activeWorkflowId || ''}
|
||||
isForeignCredential={isForeignCredential}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
{!credential && (
|
||||
<Tooltip.Content side='top'>
|
||||
<p>Please select Microsoft Teams credentials first</p>
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// Wealthbox selector
|
||||
if (isWealthbox) {
|
||||
const credential = (connectedCredential as string) || ''
|
||||
if (subBlock.id === 'contactId') {
|
||||
const itemType = 'contact'
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div className='w-full'>
|
||||
<WealthboxFileSelector
|
||||
value={
|
||||
(isPreview && previewValue !== undefined
|
||||
? (previewValue as string)
|
||||
: (storeValue as string)) || ''
|
||||
}
|
||||
onChange={(val) => {
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, val)
|
||||
}}
|
||||
provider='wealthbox'
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
serviceId={subBlock.serviceId}
|
||||
label={subBlock.placeholder || `Select ${itemType}`}
|
||||
disabled={finalDisabled || shouldForceDisable}
|
||||
showPreview={true}
|
||||
credentialId={credential}
|
||||
itemType={itemType}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
{!credential && (
|
||||
<Tooltip.Content side='top'>
|
||||
<p>Please select Wealthbox credentials first</p>
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
)
|
||||
}
|
||||
// noteId or taskId now use short-input
|
||||
return null
|
||||
}
|
||||
|
||||
// Default to Google Drive picker
|
||||
{
|
||||
const credential = ((isPreview && previewContextValues?.credential?.value) ||
|
||||
(connectedCredential as string) ||
|
||||
'') as string
|
||||
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div className='w-full'>
|
||||
<GoogleDrivePicker
|
||||
value={coerceToIdString(
|
||||
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
|
||||
)}
|
||||
onChange={(val) => {
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, val)
|
||||
}}
|
||||
provider={provider}
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
label={subBlock.placeholder || 'Select file'}
|
||||
disabled={finalDisabled || shouldForceDisable}
|
||||
serviceId={subBlock.serviceId}
|
||||
mimeTypeFilter={subBlock.mimeType}
|
||||
showPreview={true}
|
||||
clientId={clientId}
|
||||
apiKey={apiKey}
|
||||
credentialId={credential}
|
||||
workflowId={workflowIdFromUrl}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
{!credential && (
|
||||
<Tooltip.Content side='top'>
|
||||
<p>Please select Google Drive credentials first</p>
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<SelectorCombobox
|
||||
blockId={blockId}
|
||||
subBlock={subBlock}
|
||||
selectorKey={selectorResolution.key}
|
||||
selectorContext={selectorResolution.context}
|
||||
disabled={disabledReason}
|
||||
isPreview={isPreview}
|
||||
previewValue={previewValue ?? null}
|
||||
placeholder={subBlock.placeholder || 'Select resource'}
|
||||
allowSearch={selectorResolution.allowSearch}
|
||||
onOptionChange={(value) => {
|
||||
if (!isPreview) {
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, value)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,20 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { useRef, useState } from 'react'
|
||||
import { ChevronDown, X } from 'lucide-react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Button, Combobox } from '@/components/emcn/components'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
|
||||
@@ -59,31 +48,24 @@ export function FileUpload({
|
||||
previewValue,
|
||||
disabled = false,
|
||||
}: FileUploadProps) {
|
||||
// State management - handle both single file and array of files
|
||||
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId)
|
||||
const [uploadingFiles, setUploadingFiles] = useState<UploadingFile[]>([])
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
const [workspaceFiles, setWorkspaceFiles] = useState<WorkspaceFileRecord[]>([])
|
||||
const [loadingWorkspaceFiles, setLoadingWorkspaceFiles] = useState(false)
|
||||
const [uploadError, setUploadError] = useState<string | null>(null)
|
||||
const [addMoreOpen, setAddMoreOpen] = useState(false)
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
|
||||
// For file deletion status
|
||||
const [deletingFiles, setDeletingFiles] = useState<Record<string, boolean>>({})
|
||||
|
||||
// Refs
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Stores
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
const params = useParams()
|
||||
const workspaceId = params?.workspaceId as string
|
||||
|
||||
// Use preview value when in preview mode, otherwise use store value
|
||||
const value = isPreview ? previewValue : storeValue
|
||||
|
||||
// Load workspace files function
|
||||
const loadWorkspaceFiles = async () => {
|
||||
if (!workspaceId || isPreview) return
|
||||
|
||||
@@ -102,10 +84,8 @@ export function FileUpload({
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out already selected files
|
||||
const availableWorkspaceFiles = workspaceFiles.filter((workspaceFile) => {
|
||||
const existingFiles = Array.isArray(value) ? value : value ? [value] : []
|
||||
// Check if this workspace file is already added (match by name or key)
|
||||
return !existingFiles.some(
|
||||
(existing) =>
|
||||
existing.name === workspaceFile.name ||
|
||||
@@ -114,9 +94,12 @@ export function FileUpload({
|
||||
)
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
void loadWorkspaceFiles()
|
||||
}, [workspaceId])
|
||||
|
||||
/**
|
||||
* Opens file dialog
|
||||
* Prevents event propagation to avoid ReactFlow capturing the event
|
||||
*/
|
||||
const handleOpenFileDialog = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -159,18 +142,15 @@ export function FileUpload({
|
||||
const files = e.target.files
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
// Get existing files and their total size
|
||||
const existingFiles = Array.isArray(value) ? value : value ? [value] : []
|
||||
const existingTotalSize = existingFiles.reduce((sum, file) => sum + file.size, 0)
|
||||
|
||||
// Validate file sizes
|
||||
const maxSizeInBytes = maxSize * 1024 * 1024
|
||||
const validFiles: File[] = []
|
||||
let totalNewSize = 0
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i]
|
||||
// Check if adding this file would exceed the total limit
|
||||
if (existingTotalSize + totalNewSize + file.size > maxSizeInBytes) {
|
||||
logger.error(
|
||||
`Adding ${file.name} would exceed the maximum size limit of ${maxSize}MB`,
|
||||
@@ -184,7 +164,6 @@ export function FileUpload({
|
||||
|
||||
if (validFiles.length === 0) return
|
||||
|
||||
// Create placeholder uploading files - ensure unique IDs
|
||||
const uploading = validFiles.map((file) => ({
|
||||
id: `upload-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
name: file.name,
|
||||
@@ -194,13 +173,11 @@ export function FileUpload({
|
||||
setUploadingFiles(uploading)
|
||||
setUploadProgress(0)
|
||||
|
||||
// Track progress simulation interval
|
||||
let progressInterval: NodeJS.Timeout | null = null
|
||||
|
||||
try {
|
||||
setUploadError(null) // Clear previous errors
|
||||
setUploadError(null)
|
||||
|
||||
// Simulate upload progress
|
||||
progressInterval = setInterval(() => {
|
||||
setUploadProgress((prev) => {
|
||||
const newProgress = prev + Math.random() * 10
|
||||
@@ -211,20 +188,16 @@ export function FileUpload({
|
||||
const uploadedFiles: UploadedFile[] = []
|
||||
const uploadErrors: string[] = []
|
||||
|
||||
// Upload each file via server (workspace files need DB records)
|
||||
for (const file of validFiles) {
|
||||
try {
|
||||
// Create FormData for upload
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('context', 'workspace')
|
||||
|
||||
// Add workspace ID for workspace-scoped storage
|
||||
if (workspaceId) {
|
||||
formData.append('workspaceId', workspaceId)
|
||||
}
|
||||
|
||||
// Upload the file via server
|
||||
const response = await fetch('/api/files/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
@@ -232,37 +205,30 @@ export function FileUpload({
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Handle error response
|
||||
if (!response.ok) {
|
||||
const errorMessage = data.error || `Failed to upload file: ${response.status}`
|
||||
uploadErrors.push(`${file.name}: ${errorMessage}`)
|
||||
|
||||
// Set error message with conditional auto-dismiss
|
||||
setUploadError(errorMessage)
|
||||
|
||||
// Only auto-dismiss duplicate errors, keep other errors (like storage limits) visible
|
||||
if (data.isDuplicate || response.status === 409) {
|
||||
setTimeout(() => setUploadError(null), 5000)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if response has error even with 200 status
|
||||
if (data.success === false) {
|
||||
const errorMessage = data.error || 'Upload failed'
|
||||
uploadErrors.push(`${file.name}: ${errorMessage}`)
|
||||
|
||||
// Set error message with conditional auto-dismiss
|
||||
setUploadError(errorMessage)
|
||||
|
||||
// Only auto-dismiss duplicate errors, keep other errors (like storage limits) visible
|
||||
if (data.isDuplicate) {
|
||||
setTimeout(() => setUploadError(null), 5000)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Process successful upload - handle both workspace and regular uploads
|
||||
uploadedFiles.push({
|
||||
name: file.name,
|
||||
path: data.file?.url || data.url, // Workspace: data.file.url, Non-workspace: data.url
|
||||
@@ -277,7 +243,6 @@ export function FileUpload({
|
||||
}
|
||||
}
|
||||
|
||||
// Clear progress interval
|
||||
if (progressInterval) {
|
||||
clearInterval(progressInterval)
|
||||
progressInterval = null
|
||||
@@ -285,11 +250,9 @@ export function FileUpload({
|
||||
|
||||
setUploadProgress(100)
|
||||
|
||||
// Send consolidated notification about uploaded files
|
||||
if (uploadedFiles.length > 0) {
|
||||
setUploadError(null) // Clear error on successful upload
|
||||
setUploadError(null)
|
||||
|
||||
// Refresh workspace files list to keep dropdown up to date
|
||||
if (workspaceId) {
|
||||
void loadWorkspaceFiles()
|
||||
}
|
||||
@@ -304,7 +267,6 @@ export function FileUpload({
|
||||
}
|
||||
}
|
||||
|
||||
// Send consolidated error notification if any
|
||||
if (uploadErrors.length > 0) {
|
||||
if (uploadErrors.length === 1) {
|
||||
logger.error(uploadErrors[0], activeWorkflowId)
|
||||
@@ -316,30 +278,23 @@ export function FileUpload({
|
||||
}
|
||||
}
|
||||
|
||||
// Update the file value in state based on multiple setting
|
||||
if (multiple) {
|
||||
// For multiple files: Append to existing files if any
|
||||
const existingFiles = Array.isArray(value) ? value : value ? [value] : []
|
||||
// Create a map to identify duplicates by url
|
||||
const uniqueFiles = new Map()
|
||||
|
||||
// Add existing files to the map
|
||||
existingFiles.forEach((file) => {
|
||||
uniqueFiles.set(file.url || file.path, file) // Use url, fallback to path for backward compatibility
|
||||
uniqueFiles.set(file.url || file.path, file)
|
||||
})
|
||||
|
||||
// Add new files to the map (will overwrite if same path)
|
||||
uploadedFiles.forEach((file) => {
|
||||
uniqueFiles.set(file.path, file)
|
||||
})
|
||||
|
||||
// Convert map values back to array
|
||||
const newFiles = Array.from(uniqueFiles.values())
|
||||
|
||||
setStoreValue(newFiles)
|
||||
useWorkflowStore.getState().triggerUpdate()
|
||||
} else {
|
||||
// For single file: Replace with last uploaded file
|
||||
setStoreValue(uploadedFiles[0] || null)
|
||||
useWorkflowStore.getState().triggerUpdate()
|
||||
}
|
||||
@@ -349,7 +304,6 @@ export function FileUpload({
|
||||
activeWorkflowId
|
||||
)
|
||||
} finally {
|
||||
// Clean up and reset upload state
|
||||
if (progressInterval) {
|
||||
clearInterval(progressInterval)
|
||||
}
|
||||
@@ -368,8 +322,6 @@ export function FileUpload({
|
||||
const selectedFile = workspaceFiles.find((f) => f.id === fileId)
|
||||
if (!selectedFile) return
|
||||
|
||||
// Convert workspace file record to uploaded file format
|
||||
// Path will be converted to presigned URL during execution if needed
|
||||
const uploadedFile: UploadedFile = {
|
||||
name: selectedFile.name,
|
||||
path: selectedFile.path,
|
||||
@@ -378,7 +330,6 @@ export function FileUpload({
|
||||
}
|
||||
|
||||
if (multiple) {
|
||||
// For multiple files: Append to existing
|
||||
const existingFiles = Array.isArray(value) ? value : value ? [value] : []
|
||||
const uniqueFiles = new Map()
|
||||
|
||||
@@ -391,7 +342,6 @@ export function FileUpload({
|
||||
|
||||
setStoreValue(newFiles)
|
||||
} else {
|
||||
// For single file: Replace
|
||||
setStoreValue(uploadedFile)
|
||||
}
|
||||
|
||||
@@ -408,19 +358,15 @@ export function FileUpload({
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
// Mark this file as being deleted
|
||||
setDeletingFiles((prev) => ({ ...prev, [file.path || '']: true }))
|
||||
|
||||
try {
|
||||
// Check if this is a workspace file (decoded path contains workspaceId pattern)
|
||||
const decodedPath = file.path ? decodeURIComponent(file.path) : ''
|
||||
const isWorkspaceFile =
|
||||
workspaceId &&
|
||||
(decodedPath.includes(`/${workspaceId}/`) || decodedPath.includes(`${workspaceId}/`))
|
||||
|
||||
if (!isWorkspaceFile) {
|
||||
// Only delete from storage if it's NOT a workspace file
|
||||
// Workspace files are permanent and managed through Settings
|
||||
const response = await fetch('/api/files/delete', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -436,14 +382,11 @@ export function FileUpload({
|
||||
}
|
||||
}
|
||||
|
||||
// Update the UI state (remove from selection)
|
||||
if (multiple) {
|
||||
// For multiple files: Remove the specific file
|
||||
const filesArray = Array.isArray(value) ? value : value ? [value] : []
|
||||
const updatedFiles = filesArray.filter((f) => f.path !== file.path)
|
||||
setStoreValue(updatedFiles.length > 0 ? updatedFiles : null)
|
||||
} else {
|
||||
// For single file: Clear the value
|
||||
setStoreValue(null)
|
||||
}
|
||||
|
||||
@@ -454,7 +397,6 @@ export function FileUpload({
|
||||
activeWorkflowId
|
||||
)
|
||||
} finally {
|
||||
// Remove file from the deleting state
|
||||
setDeletingFiles((prev) => {
|
||||
const updated = { ...prev }
|
||||
delete updated[file.path || '']
|
||||
@@ -463,80 +405,6 @@ export function FileUpload({
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles deletion of all files (for multiple mode)
|
||||
*/
|
||||
const handleRemoveAllFiles = async (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
if (!value) return
|
||||
|
||||
const filesToDelete = Array.isArray(value) ? value : [value]
|
||||
|
||||
// Mark all files as deleting
|
||||
const deletingStatus: Record<string, boolean> = {}
|
||||
filesToDelete.forEach((file) => {
|
||||
deletingStatus[file.path || ''] = true
|
||||
})
|
||||
setDeletingFiles(deletingStatus)
|
||||
|
||||
// Clear input state immediately for better UX
|
||||
setStoreValue(null)
|
||||
useWorkflowStore.getState().triggerUpdate()
|
||||
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
|
||||
// Track successful and failed deletions
|
||||
const deletionResults = {
|
||||
success: 0,
|
||||
failures: [] as string[],
|
||||
}
|
||||
|
||||
// Delete each file
|
||||
for (const file of filesToDelete) {
|
||||
try {
|
||||
const response = await fetch('/api/files/delete', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ filePath: file.path }),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
deletionResults.success++
|
||||
} else {
|
||||
const errorData = await response.json().catch(() => ({ error: response.statusText }))
|
||||
const errorMessage = errorData.error || `Failed to delete file: ${response.status}`
|
||||
deletionResults.failures.push(`${file.name}: ${errorMessage}`)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to delete file ${file.name}:`, error)
|
||||
deletionResults.failures.push(
|
||||
`${file.name}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Show error notification if any deletions failed
|
||||
if (deletionResults.failures.length > 0) {
|
||||
if (deletionResults.failures.length === 1) {
|
||||
logger.error(`Failed to delete file: ${deletionResults.failures[0]}`, activeWorkflowId)
|
||||
} else {
|
||||
logger.error(
|
||||
`Failed to delete ${deletionResults.failures.length} files: ${deletionResults.failures.join('; ')}`,
|
||||
activeWorkflowId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
setDeletingFiles({})
|
||||
}
|
||||
|
||||
// Helper to render a single file item
|
||||
const renderFileItem = (file: UploadedFile) => {
|
||||
const fileKey = file.path || ''
|
||||
const isDeleting = deletingFiles[fileKey]
|
||||
@@ -544,19 +412,16 @@ export function FileUpload({
|
||||
return (
|
||||
<div
|
||||
key={fileKey}
|
||||
className='flex items-center justify-between rounded border border-border bg-background px-3 py-2'
|
||||
className='flex items-center justify-between rounded-[4px] border border-[var(--surface-11)] bg-[var(--surface-6)] px-[8px] py-[6px] hover:border-[var(--surface-14)] hover:bg-[var(--surface-9)] dark:bg-[var(--surface-9)] dark:hover:bg-[var(--surface-11)]'
|
||||
>
|
||||
<div className='flex-1 truncate pr-2'>
|
||||
<div className='truncate font-normal text-sm' title={file.name}>
|
||||
{truncateMiddle(file.name)}
|
||||
</div>
|
||||
<div className='text-muted-foreground text-xs'>{formatFileSize(file.size)}</div>
|
||||
<div className='flex-1 truncate pr-2 text-sm' title={file.name}>
|
||||
<span className='text-[var(--text-primary)]'>{truncateMiddle(file.name)}</span>
|
||||
<span className='ml-2 text-[var(--text-muted)]'>({formatFileSize(file.size)})</span>
|
||||
</div>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-8 w-8 shrink-0'
|
||||
className='h-6 w-6 shrink-0 p-0'
|
||||
onClick={(e) => handleRemoveFile(file, e)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
@@ -570,16 +435,15 @@ export function FileUpload({
|
||||
)
|
||||
}
|
||||
|
||||
// Render a placeholder item for files being uploaded
|
||||
const renderUploadingItem = (file: UploadingFile) => {
|
||||
return (
|
||||
<div
|
||||
key={file.id}
|
||||
className='flex items-center justify-between rounded border border-border bg-background px-3 py-2'
|
||||
className='flex items-center justify-between rounded-[4px] border border-[var(--surface-11)] bg-[var(--surface-6)] px-[8px] py-[6px] dark:bg-[var(--surface-9)]'
|
||||
>
|
||||
<div className='flex-1 truncate pr-2'>
|
||||
<div className='truncate font-normal text-sm'>{file.name}</div>
|
||||
<div className='text-muted-foreground text-xs'>{formatFileSize(file.size)}</div>
|
||||
<div className='flex-1 truncate pr-2 text-sm'>
|
||||
<span className='text-[var(--text-primary)]'>{file.name}</span>
|
||||
<span className='ml-2 text-[var(--text-muted)]'>({formatFileSize(file.size)})</span>
|
||||
</div>
|
||||
<div className='flex h-8 w-8 shrink-0 items-center justify-center'>
|
||||
<div className='h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
|
||||
@@ -588,11 +452,43 @@ export function FileUpload({
|
||||
)
|
||||
}
|
||||
|
||||
// Get files array regardless of multiple setting
|
||||
const filesArray = Array.isArray(value) ? value : value ? [value] : []
|
||||
const hasFiles = filesArray.length > 0
|
||||
const isUploading = uploadingFiles.length > 0
|
||||
|
||||
const comboboxOptions = useMemo(
|
||||
() => [
|
||||
{ label: 'Upload New File', value: '__upload_new__' },
|
||||
...availableWorkspaceFiles.map((file) => ({
|
||||
label: file.name,
|
||||
value: file.id,
|
||||
})),
|
||||
],
|
||||
[availableWorkspaceFiles]
|
||||
)
|
||||
|
||||
const handleComboboxChange = (value: string) => {
|
||||
setInputValue(value)
|
||||
|
||||
const isValidOption =
|
||||
value === '__upload_new__' || availableWorkspaceFiles.some((file) => file.id === value)
|
||||
|
||||
if (!isValidOption) {
|
||||
return
|
||||
}
|
||||
|
||||
setInputValue('')
|
||||
|
||||
if (value === '__upload_new__') {
|
||||
handleOpenFileDialog({
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
} as React.MouseEvent)
|
||||
} else {
|
||||
handleSelectWorkspaceFile(value)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='w-full' onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
@@ -614,7 +510,6 @@ export function FileUpload({
|
||||
<div className='mb-2 space-y-2'>
|
||||
{/* Only show files that aren't currently uploading */}
|
||||
{filesArray.map((file) => {
|
||||
// Don't show files that have duplicates in the uploading list
|
||||
const isCurrentlyUploading = uploadingFiles.some(
|
||||
(uploadingFile) => uploadingFile.name === file.name
|
||||
)
|
||||
@@ -641,73 +536,19 @@ export function FileUpload({
|
||||
{/* Add More dropdown for multiple files */}
|
||||
{hasFiles && multiple && !isUploading && (
|
||||
<div>
|
||||
<Popover
|
||||
open={addMoreOpen}
|
||||
<Combobox
|
||||
options={comboboxOptions}
|
||||
value={inputValue}
|
||||
onChange={handleComboboxChange}
|
||||
onOpenChange={(open) => {
|
||||
setAddMoreOpen(open)
|
||||
if (open) void loadWorkspaceFiles()
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
role='combobox'
|
||||
aria-expanded={addMoreOpen}
|
||||
className='relative w-full justify-between'
|
||||
disabled={disabled || loadingWorkspaceFiles}
|
||||
>
|
||||
<span className='truncate font-normal'>+ Add More</span>
|
||||
<ChevronDown className='absolute right-3 h-4 w-4 shrink-0 opacity-50' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-[320px] p-0' align='start'>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder='Search files...'
|
||||
className='text-foreground placeholder:text-muted-foreground'
|
||||
/>
|
||||
<CommandList onWheel={(e) => e.stopPropagation()}>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value='upload_new'
|
||||
onSelect={() => {
|
||||
setAddMoreOpen(false)
|
||||
handleOpenFileDialog({
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
} as React.MouseEvent)
|
||||
}}
|
||||
>
|
||||
Upload New File
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
<CommandEmpty>
|
||||
{availableWorkspaceFiles.length === 0
|
||||
? 'No files available.'
|
||||
: 'No files found.'}
|
||||
</CommandEmpty>
|
||||
{availableWorkspaceFiles.length > 0 && (
|
||||
<CommandGroup heading='Workspace Files'>
|
||||
{availableWorkspaceFiles.map((file) => (
|
||||
<CommandItem
|
||||
key={file.id}
|
||||
value={file.name}
|
||||
onSelect={() => {
|
||||
handleSelectWorkspaceFile(file.id)
|
||||
setAddMoreOpen(false)
|
||||
}}
|
||||
>
|
||||
<span className='truncate' title={file.name}>
|
||||
{truncateMiddle(file.name)}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
placeholder={loadingWorkspaceFiles ? 'Loading files...' : '+ Add More'}
|
||||
disabled={disabled || loadingWorkspaceFiles}
|
||||
editable={true}
|
||||
filterOptions={true}
|
||||
isLoading={loadingWorkspaceFiles}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -715,75 +556,19 @@ export function FileUpload({
|
||||
{/* Show dropdown selector if no files and not uploading */}
|
||||
{!hasFiles && !isUploading && (
|
||||
<div className='flex items-center'>
|
||||
<Popover
|
||||
open={pickerOpen}
|
||||
<Combobox
|
||||
options={comboboxOptions}
|
||||
value={inputValue}
|
||||
onChange={handleComboboxChange}
|
||||
onOpenChange={(open) => {
|
||||
setPickerOpen(open)
|
||||
if (open) void loadWorkspaceFiles()
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
role='combobox'
|
||||
aria-expanded={pickerOpen}
|
||||
className='relative w-full justify-between'
|
||||
disabled={disabled || loadingWorkspaceFiles}
|
||||
>
|
||||
<span className='truncate font-normal'>
|
||||
{loadingWorkspaceFiles ? 'Loading files...' : 'Select or upload file'}
|
||||
</span>
|
||||
<ChevronDown className='absolute right-3 h-4 w-4 shrink-0 opacity-50' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-[320px] p-0' align='start'>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder='Search files...'
|
||||
className='text-foreground placeholder:text-muted-foreground'
|
||||
/>
|
||||
<CommandList onWheel={(e) => e.stopPropagation()}>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value='upload_new'
|
||||
onSelect={() => {
|
||||
setPickerOpen(false)
|
||||
handleOpenFileDialog({
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
} as React.MouseEvent)
|
||||
}}
|
||||
>
|
||||
Upload New File
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
<CommandEmpty>
|
||||
{availableWorkspaceFiles.length === 0
|
||||
? 'No files available.'
|
||||
: 'No files found.'}
|
||||
</CommandEmpty>
|
||||
{availableWorkspaceFiles.length > 0 && (
|
||||
<CommandGroup heading='Workspace Files'>
|
||||
{availableWorkspaceFiles.map((file) => (
|
||||
<CommandItem
|
||||
key={file.id}
|
||||
value={file.name}
|
||||
onSelect={() => {
|
||||
handleSelectWorkspaceFile(file.id)
|
||||
setPickerOpen(false)
|
||||
}}
|
||||
>
|
||||
<span className='truncate' title={file.name}>
|
||||
{truncateMiddle(file.name)}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
placeholder={loadingWorkspaceFiles ? 'Loading files...' : 'Select or upload file'}
|
||||
disabled={disabled || loadingWorkspaceFiles}
|
||||
editable={true}
|
||||
filterOptions={true}
|
||||
isLoading={loadingWorkspaceFiles}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
type FolderInfo,
|
||||
FolderSelector,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/folder-selector/folder-selector'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
|
||||
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-depends-on-gate'
|
||||
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-foreign-credential'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { resolveSelectorForSubBlock } from '@/hooks/selectors/resolution'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
@@ -27,19 +25,19 @@ export function FolderSelectorInput({
|
||||
isPreview = false,
|
||||
previewValue,
|
||||
}: FolderSelectorInputProps) {
|
||||
const [storeValue, _setStoreValue] = useSubBlockValue(blockId, subBlock.id)
|
||||
const [storeValue] = useSubBlockValue(blockId, subBlock.id)
|
||||
const [connectedCredential] = useSubBlockValue(blockId, 'credential')
|
||||
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
const [selectedFolderId, setSelectedFolderId] = useState<string>('')
|
||||
const [_folderInfo, setFolderInfo] = useState<FolderInfo | null>(null)
|
||||
const provider = (subBlock.provider || subBlock.serviceId || 'google-email').toLowerCase()
|
||||
const providerKey = (subBlock.provider ?? subBlock.serviceId ?? '').toLowerCase()
|
||||
const credentialProvider = subBlock.serviceId ?? subBlock.provider
|
||||
const isCopyDestinationSelector =
|
||||
subBlock.canonicalParamId === 'copyDestinationId' ||
|
||||
subBlock.id === 'copyDestinationFolder' ||
|
||||
subBlock.id === 'manualCopyDestinationFolder'
|
||||
const { isForeignCredential } = useForeignCredential(
|
||||
subBlock.provider || subBlock.serviceId || 'outlook',
|
||||
credentialProvider,
|
||||
(connectedCredential as string) || ''
|
||||
)
|
||||
|
||||
@@ -48,26 +46,22 @@ export function FolderSelectorInput({
|
||||
|
||||
// Get the current value from the store or prop value if in preview mode
|
||||
useEffect(() => {
|
||||
// When gated/disabled, do not set defaults or write to store
|
||||
if (finalDisabled) return
|
||||
if (isPreview && previewValue !== undefined) {
|
||||
setSelectedFolderId(previewValue)
|
||||
return
|
||||
}
|
||||
const current = storeValue as string | undefined
|
||||
if (current && typeof current === 'string') {
|
||||
if (current) {
|
||||
setSelectedFolderId(current)
|
||||
return
|
||||
}
|
||||
const shouldDefaultInbox = provider !== 'outlook' && !isCopyDestinationSelector
|
||||
const shouldDefaultInbox = providerKey === 'gmail' && !isCopyDestinationSelector
|
||||
if (shouldDefaultInbox) {
|
||||
const defaultValue = 'INBOX'
|
||||
setSelectedFolderId(defaultValue)
|
||||
setSelectedFolderId('INBOX')
|
||||
if (!isPreview) {
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, defaultValue)
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, 'INBOX')
|
||||
}
|
||||
} else {
|
||||
setSelectedFolderId('')
|
||||
}
|
||||
}, [
|
||||
blockId,
|
||||
@@ -77,33 +71,46 @@ export function FolderSelectorInput({
|
||||
isPreview,
|
||||
previewValue,
|
||||
finalDisabled,
|
||||
providerKey,
|
||||
isCopyDestinationSelector,
|
||||
])
|
||||
|
||||
// Handle folder selection
|
||||
const handleFolderChange = useCallback(
|
||||
(folderId: string, info?: FolderInfo) => {
|
||||
setSelectedFolderId(folderId)
|
||||
setFolderInfo(info || null)
|
||||
const credentialId = (connectedCredential as string) || ''
|
||||
const missingCredential = credentialId.length === 0
|
||||
const selectorResolution = useMemo(
|
||||
() =>
|
||||
resolveSelectorForSubBlock(subBlock, {
|
||||
credentialId: credentialId || undefined,
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
}),
|
||||
[subBlock, credentialId, activeWorkflowId]
|
||||
)
|
||||
|
||||
const handleChange = useCallback(
|
||||
(value: string) => {
|
||||
setSelectedFolderId(value)
|
||||
if (!isPreview) {
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, folderId)
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, value)
|
||||
}
|
||||
},
|
||||
[blockId, subBlock.id, collaborativeSetSubblockValue, isPreview]
|
||||
)
|
||||
|
||||
return (
|
||||
<FolderSelector
|
||||
value={selectedFolderId}
|
||||
onChange={handleFolderChange}
|
||||
provider={provider}
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
label={subBlock.placeholder || 'Select folder'}
|
||||
disabled={finalDisabled}
|
||||
serviceId={subBlock.serviceId}
|
||||
onFolderInfoChange={setFolderInfo}
|
||||
credentialId={(connectedCredential as string) || ''}
|
||||
workflowId={activeWorkflowId || ''}
|
||||
isForeignCredential={isForeignCredential}
|
||||
<SelectorCombobox
|
||||
blockId={blockId}
|
||||
subBlock={subBlock}
|
||||
selectorKey={selectorResolution?.key ?? 'gmail.labels'}
|
||||
selectorContext={
|
||||
selectorResolution?.context ?? { credentialId, workflowId: activeWorkflowId || '' }
|
||||
}
|
||||
disabled={
|
||||
finalDisabled || isForeignCredential || missingCredential || !selectorResolution?.key
|
||||
}
|
||||
isPreview={isPreview}
|
||||
previewValue={previewValue ?? null}
|
||||
placeholder={subBlock.placeholder || 'Select folder'}
|
||||
onOptionChange={handleChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,533 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Check, ChevronDown, RefreshCw } from 'lucide-react'
|
||||
import { GmailIcon, OutlookIcon } from '@/components/icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { type Credential, getProviderIdFromServiceId, getServiceIdFromScopes } from '@/lib/oauth'
|
||||
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
|
||||
import { useDisplayNamesStore } from '@/stores/display-names/store'
|
||||
|
||||
const logger = createLogger('FolderSelector')
|
||||
|
||||
export interface FolderInfo {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
messagesTotal?: number
|
||||
messagesUnread?: number
|
||||
}
|
||||
|
||||
interface FolderSelectorProps {
|
||||
value: string
|
||||
onChange: (value: string, folderInfo?: FolderInfo) => void
|
||||
provider: string
|
||||
requiredScopes?: string[]
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
serviceId?: string
|
||||
onFolderInfoChange?: (folderInfo: FolderInfo | null) => void
|
||||
isPreview?: boolean
|
||||
previewValue?: any | null
|
||||
credentialId?: string
|
||||
workflowId?: string
|
||||
isForeignCredential?: boolean
|
||||
}
|
||||
|
||||
export function FolderSelector({
|
||||
value,
|
||||
onChange,
|
||||
provider,
|
||||
requiredScopes = [],
|
||||
label = 'Select folder',
|
||||
disabled = false,
|
||||
serviceId,
|
||||
onFolderInfoChange,
|
||||
isPreview = false,
|
||||
previewValue,
|
||||
credentialId,
|
||||
workflowId,
|
||||
isForeignCredential = false,
|
||||
}: FolderSelectorProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [credentials, setCredentials] = useState<Credential[]>([])
|
||||
const [folders, setFolders] = useState<FolderInfo[]>([])
|
||||
const [selectedCredentialId, setSelectedCredentialId] = useState<Credential['id'] | ''>(
|
||||
credentialId || ''
|
||||
)
|
||||
const [selectedFolderId, setSelectedFolderId] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isLoadingSelectedFolder, setIsLoadingSelectedFolder] = useState(false)
|
||||
const [showOAuthModal, setShowOAuthModal] = useState(false)
|
||||
const initialFetchRef = useRef(false)
|
||||
|
||||
// Get cached display name
|
||||
const cachedFolderName = useDisplayNamesStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
const effectiveCredentialId = credentialId || selectedCredentialId
|
||||
const effectiveValue = isPreview && previewValue !== undefined ? previewValue : value
|
||||
if (!effectiveCredentialId || !effectiveValue) return null
|
||||
return state.cache.folders[effectiveCredentialId]?.[effectiveValue] || null
|
||||
},
|
||||
[credentialId, selectedCredentialId, value, isPreview, previewValue]
|
||||
)
|
||||
)
|
||||
|
||||
// Initialize selectedFolderId with the effective value
|
||||
useEffect(() => {
|
||||
if (isPreview && previewValue !== undefined) {
|
||||
setSelectedFolderId(previewValue || '')
|
||||
} else {
|
||||
setSelectedFolderId(value)
|
||||
}
|
||||
}, [value, isPreview, previewValue])
|
||||
|
||||
// Keep internal credential in sync with prop
|
||||
useEffect(() => {
|
||||
if (credentialId && credentialId !== selectedCredentialId) {
|
||||
setSelectedCredentialId(credentialId)
|
||||
}
|
||||
}, [credentialId, selectedCredentialId])
|
||||
|
||||
// Determine the appropriate service ID based on provider and scopes
|
||||
const getServiceId = (): string => {
|
||||
if (serviceId) return serviceId
|
||||
return getServiceIdFromScopes(provider, requiredScopes)
|
||||
}
|
||||
|
||||
// Determine the appropriate provider ID based on service and scopes
|
||||
const getProviderId = (): string => {
|
||||
const effectiveServiceId = getServiceId()
|
||||
return getProviderIdFromServiceId(effectiveServiceId)
|
||||
}
|
||||
|
||||
// Fetch available credentials for this provider
|
||||
const fetchCredentials = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const providerId = getProviderId()
|
||||
const response = await fetch(`/api/auth/oauth/credentials?provider=${providerId}`)
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setCredentials(data.credentials)
|
||||
|
||||
// Auto-select logic for credentials
|
||||
if (data.credentials.length > 0) {
|
||||
// If we already have a selected credential ID, check if it's valid
|
||||
if (
|
||||
selectedCredentialId &&
|
||||
data.credentials.some((cred: Credential) => cred.id === selectedCredentialId)
|
||||
) {
|
||||
// Keep the current selection
|
||||
} else {
|
||||
// Otherwise, select the default or first credential
|
||||
const defaultCred = data.credentials.find((cred: Credential) => cred.isDefault)
|
||||
if (defaultCred) {
|
||||
setSelectedCredentialId(defaultCred.id)
|
||||
} else if (data.credentials.length === 1) {
|
||||
setSelectedCredentialId(data.credentials[0].id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching credentials:', { error })
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [provider, getProviderId, selectedCredentialId])
|
||||
|
||||
// Fetch a single folder by ID when we have a selectedFolderId but no metadata
|
||||
const fetchFolderById = useCallback(
|
||||
async (folderId: string) => {
|
||||
if (!selectedCredentialId || !folderId) return null
|
||||
|
||||
setIsLoadingSelectedFolder(true)
|
||||
try {
|
||||
if (provider === 'outlook') {
|
||||
// Resolve Outlook folder name with owner-scoped token
|
||||
const tokenRes = await fetch('/api/auth/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ credentialId: selectedCredentialId, workflowId }),
|
||||
})
|
||||
if (!tokenRes.ok) return null
|
||||
const { accessToken } = await tokenRes.json()
|
||||
if (!accessToken) return null
|
||||
const resp = await fetch(
|
||||
`https://graph.microsoft.com/v1.0/me/mailFolders/${encodeURIComponent(folderId)}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
}
|
||||
)
|
||||
if (!resp.ok) return null
|
||||
const folder = await resp.json()
|
||||
const folderInfo: FolderInfo = {
|
||||
id: folder.id,
|
||||
name: folder.displayName,
|
||||
type: 'folder',
|
||||
messagesTotal: folder.totalItemCount,
|
||||
messagesUnread: folder.unreadItemCount,
|
||||
}
|
||||
onFolderInfoChange?.(folderInfo)
|
||||
return folderInfo
|
||||
}
|
||||
// Gmail label resolution
|
||||
const queryParams = new URLSearchParams({
|
||||
credentialId: selectedCredentialId,
|
||||
labelId: folderId,
|
||||
})
|
||||
const response = await fetch(`/api/tools/gmail/label?${queryParams.toString()}`)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
if (data.label) {
|
||||
onFolderInfoChange?.(data.label)
|
||||
return data.label
|
||||
}
|
||||
} else {
|
||||
logger.error('Error fetching folder by ID:', {
|
||||
error: await response.text(),
|
||||
})
|
||||
}
|
||||
return null
|
||||
} catch (error) {
|
||||
logger.error('Error fetching folder by ID:', { error })
|
||||
return null
|
||||
} finally {
|
||||
setIsLoadingSelectedFolder(false)
|
||||
}
|
||||
},
|
||||
[selectedCredentialId, onFolderInfoChange, provider, workflowId]
|
||||
)
|
||||
|
||||
// Fetch folders from Gmail or Outlook
|
||||
const fetchFolders = useCallback(
|
||||
async (searchQuery?: string) => {
|
||||
if (!selectedCredentialId) return
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
// Construct query parameters
|
||||
const queryParams = new URLSearchParams({
|
||||
credentialId: selectedCredentialId,
|
||||
})
|
||||
|
||||
if (searchQuery) {
|
||||
queryParams.append('query', searchQuery)
|
||||
}
|
||||
|
||||
// Determine the API endpoint based on provider
|
||||
let apiEndpoint: string
|
||||
if (provider === 'outlook') {
|
||||
// Skip list fetch for collaborators; only show selected
|
||||
if (isForeignCredential) {
|
||||
setFolders([])
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
apiEndpoint = `/api/tools/outlook/folders?${queryParams.toString()}`
|
||||
} else {
|
||||
// Default to Gmail
|
||||
apiEndpoint = `/api/tools/gmail/labels?${queryParams.toString()}`
|
||||
}
|
||||
|
||||
const response = await fetch(apiEndpoint)
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
const folderList = provider === 'outlook' ? data.folders : data.labels
|
||||
setFolders(folderList || [])
|
||||
|
||||
// Cache folder names in display names store
|
||||
if (selectedCredentialId && folderList) {
|
||||
const folderMap = folderList.reduce(
|
||||
(acc: Record<string, string>, folder: FolderInfo) => {
|
||||
acc[folder.id] = folder.name
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
useDisplayNamesStore
|
||||
.getState()
|
||||
.setDisplayNames('folders', selectedCredentialId, folderMap)
|
||||
}
|
||||
|
||||
// Only notify parent if callback exists
|
||||
if (selectedFolderId && onFolderInfoChange) {
|
||||
const folderInfo = folderList.find(
|
||||
(folder: FolderInfo) => folder.id === selectedFolderId
|
||||
)
|
||||
if (folderInfo) {
|
||||
onFolderInfoChange(folderInfo)
|
||||
} else if (!searchQuery && provider !== 'outlook') {
|
||||
// Only try to fetch by ID for Gmail if this is not a search query
|
||||
// and we couldn't find the folder in the list
|
||||
fetchFolderById(selectedFolderId)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const text = await response.text()
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
logger.info('Folder list fetch unauthorized (expected for collaborator)')
|
||||
} else {
|
||||
logger.warn('Error fetching folders', { status: response.status, text })
|
||||
}
|
||||
setFolders([])
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching folders:', { error })
|
||||
setFolders([])
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
},
|
||||
[
|
||||
selectedCredentialId,
|
||||
selectedFolderId,
|
||||
onFolderInfoChange,
|
||||
fetchFolderById,
|
||||
provider,
|
||||
isForeignCredential,
|
||||
]
|
||||
)
|
||||
|
||||
// Fetch credentials on initial mount
|
||||
useEffect(() => {
|
||||
if (disabled) return
|
||||
if (!initialFetchRef.current) {
|
||||
fetchCredentials()
|
||||
initialFetchRef.current = true
|
||||
}
|
||||
}, [fetchCredentials, disabled])
|
||||
|
||||
// Fetch folders when credential is selected
|
||||
useEffect(() => {
|
||||
if (disabled) return
|
||||
if (selectedCredentialId) {
|
||||
fetchFolders()
|
||||
}
|
||||
}, [selectedCredentialId, fetchFolders, disabled])
|
||||
|
||||
// Keep internal selectedFolderId in sync with the value prop
|
||||
useEffect(() => {
|
||||
if (disabled) return
|
||||
const currentValue = isPreview ? previewValue : value
|
||||
if (currentValue !== selectedFolderId) {
|
||||
setSelectedFolderId(currentValue || '')
|
||||
}
|
||||
}, [value, isPreview, previewValue, disabled, selectedFolderId])
|
||||
|
||||
// Handle folder selection
|
||||
const handleSelectFolder = (folder: FolderInfo) => {
|
||||
setSelectedFolderId(folder.id)
|
||||
if (!isPreview) {
|
||||
onChange(folder.id, folder)
|
||||
}
|
||||
onFolderInfoChange?.(folder)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
// Handle adding a new credential
|
||||
const handleAddCredential = () => {
|
||||
// Show the OAuth modal
|
||||
setShowOAuthModal(true)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
if (value.length > 2) {
|
||||
fetchFolders(value)
|
||||
} else if (value.length === 0) {
|
||||
fetchFolders()
|
||||
}
|
||||
}
|
||||
|
||||
const getFolderIcon = (size: 'sm' | 'md' = 'sm') => {
|
||||
const iconSize = size === 'sm' ? 'h-4 w-4' : 'h-5 w-5'
|
||||
if (provider === 'gmail') {
|
||||
return <GmailIcon className={iconSize} />
|
||||
}
|
||||
if (provider === 'outlook') {
|
||||
return <OutlookIcon className={iconSize} />
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const getProviderName = () => {
|
||||
if (provider === 'outlook') return 'Outlook'
|
||||
return 'Gmail'
|
||||
}
|
||||
|
||||
const getFolderLabel = () => {
|
||||
if (provider === 'outlook') return 'folders'
|
||||
return 'labels'
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-2'>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
role='combobox'
|
||||
aria-expanded={open}
|
||||
className='w-full justify-between'
|
||||
disabled={disabled || isForeignCredential}
|
||||
>
|
||||
{cachedFolderName ? (
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
{getFolderIcon('sm')}
|
||||
<span className='truncate font-normal'>{cachedFolderName}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex items-center gap-2'>
|
||||
{getFolderIcon('sm')}
|
||||
<span className='text-muted-foreground'>{label}</span>
|
||||
</div>
|
||||
)}
|
||||
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
{!isForeignCredential && (
|
||||
<PopoverContent className='w-[300px] p-0' align='start'>
|
||||
{/* Current account indicator */}
|
||||
{selectedCredentialId && credentials.length > 0 && (
|
||||
<div className='flex items-center justify-between border-b px-3 py-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
|
||||
'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
{credentials.length > 1 && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 px-2 text-xs'
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Switch
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={`Search ${getFolderLabel()}...`}
|
||||
onValueChange={handleSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{isLoading ? (
|
||||
<div className='flex items-center justify-center p-4'>
|
||||
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||
<span className='ml-2'>Loading {getFolderLabel()}...</span>
|
||||
</div>
|
||||
) : credentials.length === 0 ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No accounts connected.</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Connect a {getProviderName()} account to continue.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No {getFolderLabel()} found.</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Try a different search or account.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CommandEmpty>
|
||||
|
||||
{/* Account selection - only show if we have multiple accounts */}
|
||||
{credentials.length > 1 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
Switch Account
|
||||
</div>
|
||||
{credentials.map((cred) => (
|
||||
<CommandItem
|
||||
key={cred.id}
|
||||
value={`account-${cred.id}`}
|
||||
onSelect={() => setSelectedCredentialId(cred.id)}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='font-normal'>{cred.name}</span>
|
||||
</div>
|
||||
{cred.id === selectedCredentialId && (
|
||||
<Check className='ml-auto h-4 w-4' />
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Folders list */}
|
||||
{folders.length > 0 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
{getFolderLabel().charAt(0).toUpperCase() + getFolderLabel().slice(1)}
|
||||
</div>
|
||||
{folders.map((folder) => (
|
||||
<CommandItem
|
||||
key={folder.id}
|
||||
value={`folder-${folder.id}-${folder.name}`}
|
||||
onSelect={() => handleSelectFolder(folder)}
|
||||
>
|
||||
<div className='flex w-full items-center gap-2 overflow-hidden'>
|
||||
{getFolderIcon('sm')}
|
||||
<span className='truncate font-normal'>{folder.name}</span>
|
||||
{folder.id === selectedFolderId && (
|
||||
<Check className='ml-auto h-4 w-4' />
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Connect account option - only show if no credentials */}
|
||||
{credentials.length === 0 && (
|
||||
<CommandGroup>
|
||||
<CommandItem onSelect={handleAddCredential}>
|
||||
<div className='flex items-center gap-2 text-foreground'>
|
||||
<span>Connect {getProviderName()} account</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
)}
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{showOAuthModal && (
|
||||
<OAuthRequiredModal
|
||||
isOpen={showOAuthModal}
|
||||
onClose={() => setShowOAuthModal(false)}
|
||||
provider={provider}
|
||||
toolName={getProviderName()}
|
||||
requiredScopes={requiredScopes}
|
||||
serviceId={getServiceId()}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,17 +1,8 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Combobox, Input, Label, Textarea } from '@/components/emcn/components'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/formatted-text'
|
||||
@@ -174,7 +165,6 @@ function McpTextareaWithTags({
|
||||
onChange(newValue)
|
||||
setCursorPosition(newCursorPosition)
|
||||
|
||||
// Check for tag trigger
|
||||
const tagTrigger = checkTagTrigger(newValue, newCursorPosition)
|
||||
setShowTags(tagTrigger.show)
|
||||
}
|
||||
@@ -308,7 +298,6 @@ export function McpDynamicArgs({
|
||||
if (disabled) return
|
||||
|
||||
const current = currentArgs()
|
||||
// Store the value as-is, preserving types (number, boolean, etc.)
|
||||
const updated = { ...current, [paramName]: value }
|
||||
setToolArgs(updated)
|
||||
},
|
||||
@@ -357,29 +346,38 @@ export function McpDynamicArgs({
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'dropdown':
|
||||
case 'dropdown': {
|
||||
const dropdownOptions = useMemo(
|
||||
() =>
|
||||
(paramSchema.enum || []).map((option: any) => ({
|
||||
label: String(option),
|
||||
value: String(option),
|
||||
})),
|
||||
[paramSchema.enum]
|
||||
)
|
||||
|
||||
return (
|
||||
<div key={`${paramName}-dropdown`}>
|
||||
<Select
|
||||
<Combobox
|
||||
options={dropdownOptions}
|
||||
value={value || ''}
|
||||
onValueChange={(selectedValue) => updateParameter(paramName, selectedValue)}
|
||||
selectedValue={value || ''}
|
||||
onChange={(selectedValue) => {
|
||||
const matchedOption = dropdownOptions.find(
|
||||
(opt: { label: string; value: string }) => opt.value === selectedValue
|
||||
)
|
||||
if (matchedOption) {
|
||||
updateParameter(paramName, selectedValue)
|
||||
}
|
||||
}}
|
||||
placeholder={`Select ${formatParameterLabel(paramName).toLowerCase()}`}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className='w-full'>
|
||||
<SelectValue
|
||||
placeholder={`Select ${formatParameterLabel(paramName).toLowerCase()}`}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{paramSchema.enum?.map((option: any) => (
|
||||
<SelectItem key={String(option)} value={String(option)}>
|
||||
{String(option)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
editable={false}
|
||||
filterOptions={true}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
case 'slider': {
|
||||
const minValue = paramSchema.minimum ?? 0
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Check, ChevronDown, RefreshCw } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Combobox } from '@/components/emcn/components'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { useMcpServers } from '@/hooks/queries/mcp'
|
||||
@@ -34,7 +24,7 @@ export function McpServerSelector({
|
||||
}: McpServerSelectorProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const [open, setOpen] = useState(false)
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
|
||||
const { data: servers = [], isLoading, error } = useMcpServers(workspaceId)
|
||||
const enabledServers = servers.filter((s) => s.enabled && !s.deletedAt)
|
||||
@@ -48,87 +38,47 @@ export function McpServerSelector({
|
||||
|
||||
const selectedServer = enabledServers.find((server) => server.id === selectedServerId)
|
||||
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
setOpen(isOpen)
|
||||
// React Query automatically keeps server list fresh
|
||||
}
|
||||
const comboboxOptions = useMemo(
|
||||
() =>
|
||||
enabledServers.map((server) => ({
|
||||
label: server.name,
|
||||
value: server.id,
|
||||
})),
|
||||
[enabledServers]
|
||||
)
|
||||
|
||||
const handleSelect = (serverId: string) => {
|
||||
if (!isPreview) {
|
||||
setStoreValue(serverId)
|
||||
const handleComboboxChange = (value: string) => {
|
||||
const matchedServer = enabledServers.find((s) => s.id === value)
|
||||
if (matchedServer) {
|
||||
setInputValue(matchedServer.name)
|
||||
if (!isPreview) {
|
||||
setStoreValue(value)
|
||||
}
|
||||
} else {
|
||||
setInputValue(value)
|
||||
}
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const getDisplayText = () => {
|
||||
useEffect(() => {
|
||||
if (selectedServer) {
|
||||
return <span className='truncate font-normal'>{selectedServer.name}</span>
|
||||
setInputValue(selectedServer.name)
|
||||
} else {
|
||||
setInputValue('')
|
||||
}
|
||||
return <span className='truncate text-muted-foreground'>{label}</span>
|
||||
}
|
||||
}, [selectedServer])
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
role='combobox'
|
||||
aria-expanded={open}
|
||||
className='relative w-full justify-between'
|
||||
disabled={disabled}
|
||||
>
|
||||
<div className='flex max-w-[calc(100%-20px)] items-center overflow-hidden'>
|
||||
{getDisplayText()}
|
||||
</div>
|
||||
<ChevronDown className='absolute right-3 h-4 w-4 shrink-0 opacity-50' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-[250px] p-0' align='start'>
|
||||
<Command>
|
||||
<CommandInput placeholder='Search servers...' />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{isLoading ? (
|
||||
<div className='flex items-center justify-center p-4'>
|
||||
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||
<span className='ml-2'>Loading servers...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-destructive text-sm'>Error loading servers</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{error instanceof Error ? error.message : 'Unknown error'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No MCP servers found</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Configure MCP servers in workspace settings
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CommandEmpty>
|
||||
{enabledServers.length > 0 && (
|
||||
<CommandGroup>
|
||||
{enabledServers.map((server) => (
|
||||
<CommandItem
|
||||
key={server.id}
|
||||
value={`server-${server.id}-${server.name}`}
|
||||
onSelect={() => handleSelect(server.id)}
|
||||
className='cursor-pointer'
|
||||
>
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
<span className='truncate font-normal'>{server.name}</span>
|
||||
</div>
|
||||
{server.id === selectedServerId && <Check className='ml-auto h-4 w-4' />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Combobox
|
||||
options={comboboxOptions}
|
||||
value={inputValue}
|
||||
selectedValue={selectedServerId}
|
||||
onChange={handleComboboxChange}
|
||||
placeholder={label}
|
||||
disabled={disabled}
|
||||
editable={true}
|
||||
filterOptions={true}
|
||||
isLoading={isLoading}
|
||||
error={error instanceof Error ? error.message : null}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Check, ChevronDown, RefreshCw } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Combobox } from '@/components/emcn/components'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { useMcpTools } from '@/hooks/use-mcp-tools'
|
||||
@@ -34,7 +24,7 @@ export function McpToolSelector({
|
||||
}: McpToolSelectorProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const [open, setOpen] = useState(false)
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
|
||||
const { mcpTools, isLoading, error, refreshTools, getToolsByServer } = useMcpTools(workspaceId)
|
||||
|
||||
@@ -73,105 +63,59 @@ export function McpToolSelector({
|
||||
}
|
||||
}, [serverValue, availableTools, storeValue, setStoreValue, isPreview, disabled])
|
||||
|
||||
const comboboxOptions = useMemo(
|
||||
() =>
|
||||
availableTools.map((tool) => ({
|
||||
label: tool.name,
|
||||
value: tool.id,
|
||||
})),
|
||||
[availableTools]
|
||||
)
|
||||
|
||||
const handleComboboxChange = (value: string) => {
|
||||
const matchedTool = availableTools.find((t) => t.id === value)
|
||||
if (matchedTool) {
|
||||
setInputValue(matchedTool.name)
|
||||
if (!isPreview) {
|
||||
setStoreValue(value)
|
||||
if (matchedTool.inputSchema) {
|
||||
setSchemaCache(matchedTool.inputSchema)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setInputValue(value)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
setOpen(isOpen)
|
||||
if (isOpen && serverValue) {
|
||||
refreshTools()
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelect = (toolId: string) => {
|
||||
if (!isPreview) {
|
||||
setStoreValue(toolId)
|
||||
|
||||
const tool = availableTools.find((t) => t.id === toolId)
|
||||
if (tool?.inputSchema) {
|
||||
setSchemaCache(tool.inputSchema)
|
||||
}
|
||||
}
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const getDisplayText = () => {
|
||||
useEffect(() => {
|
||||
if (selectedTool) {
|
||||
return <span className='truncate font-normal'>{selectedTool.name}</span>
|
||||
setInputValue(selectedTool.name)
|
||||
} else {
|
||||
setInputValue('')
|
||||
}
|
||||
return (
|
||||
<span className='truncate text-muted-foreground'>
|
||||
{serverValue ? label : 'Select server first'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
}, [selectedTool])
|
||||
|
||||
const isDisabled = disabled || !serverValue
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
role='combobox'
|
||||
aria-expanded={open}
|
||||
className='relative w-full justify-between'
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<div className='flex max-w-[calc(100%-20px)] items-center overflow-hidden'>
|
||||
{getDisplayText()}
|
||||
</div>
|
||||
<ChevronDown className='absolute right-3 h-4 w-4 shrink-0 opacity-50' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-[250px] p-0' align='start'>
|
||||
<Command>
|
||||
<CommandInput placeholder='Search tools...' />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{isLoading ? (
|
||||
<div className='flex items-center justify-center p-4'>
|
||||
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||
<span className='ml-2'>Loading tools...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-destructive text-sm'>Error loading tools</p>
|
||||
<p className='text-muted-foreground text-xs'>{error}</p>
|
||||
</div>
|
||||
) : !serverValue ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No server selected</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Select an MCP server first to see available tools
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No tools found</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
The selected server has no available tools
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CommandEmpty>
|
||||
{availableTools.length > 0 && (
|
||||
<CommandGroup>
|
||||
{availableTools.map((tool) => (
|
||||
<CommandItem
|
||||
key={tool.id}
|
||||
value={`tool-${tool.id}-${tool.name}`}
|
||||
onSelect={() => handleSelect(tool.id)}
|
||||
className='cursor-pointer'
|
||||
>
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
<span className='truncate font-normal'>{tool.name}</span>
|
||||
</div>
|
||||
{tool.id === selectedToolId && <Check className='ml-auto h-4 w-4' />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Combobox
|
||||
options={comboboxOptions}
|
||||
value={inputValue}
|
||||
selectedValue={selectedToolId}
|
||||
onChange={handleComboboxChange}
|
||||
onOpenChange={handleOpenChange}
|
||||
placeholder={serverValue ? label : 'Select server first'}
|
||||
disabled={isDisabled}
|
||||
editable={true}
|
||||
filterOptions={true}
|
||||
isLoading={isLoading}
|
||||
error={error || null}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,638 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Check, ChevronDown, ExternalLink, RefreshCw, X } from 'lucide-react'
|
||||
import { JiraIcon } from '@/components/icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
type Credential,
|
||||
getProviderIdFromServiceId,
|
||||
getServiceIdFromScopes,
|
||||
type OAuthProvider,
|
||||
} from '@/lib/oauth'
|
||||
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
|
||||
import { useDisplayNamesStore } from '@/stores/display-names/store'
|
||||
|
||||
const logger = createLogger('JiraProjectSelector')
|
||||
|
||||
export interface JiraProjectInfo {
|
||||
id: string
|
||||
key: string
|
||||
name: string
|
||||
url?: string
|
||||
avatarUrl?: string
|
||||
description?: string
|
||||
projectTypeKey?: string
|
||||
simplified?: boolean
|
||||
style?: string
|
||||
isPrivate?: boolean
|
||||
}
|
||||
|
||||
interface JiraProjectSelectorProps {
|
||||
value: string
|
||||
onChange: (value: string, projectInfo?: JiraProjectInfo) => void
|
||||
provider: OAuthProvider
|
||||
requiredScopes?: string[]
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
serviceId?: string
|
||||
domain: string
|
||||
showPreview?: boolean
|
||||
onProjectInfoChange?: (projectInfo: JiraProjectInfo | null) => void
|
||||
credentialId?: string
|
||||
isForeignCredential?: boolean
|
||||
workflowId?: string
|
||||
}
|
||||
|
||||
export function JiraProjectSelector({
|
||||
value,
|
||||
onChange,
|
||||
provider,
|
||||
requiredScopes = [],
|
||||
label = 'Select Jira project',
|
||||
disabled = false,
|
||||
serviceId,
|
||||
domain,
|
||||
showPreview = true,
|
||||
onProjectInfoChange,
|
||||
credentialId,
|
||||
isForeignCredential = false,
|
||||
workflowId,
|
||||
}: JiraProjectSelectorProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [credentials, setCredentials] = useState<Credential[]>([])
|
||||
const [projects, setProjects] = useState<JiraProjectInfo[]>([])
|
||||
const [selectedCredentialId, setSelectedCredentialId] = useState<string>(credentialId || '')
|
||||
const [selectedProjectId, setSelectedProjectId] = useState(value)
|
||||
const [selectedProject, setSelectedProject] = useState<JiraProjectInfo | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [showOAuthModal, setShowOAuthModal] = useState(false)
|
||||
const initialFetchRef = useRef(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [cloudId, setCloudId] = useState<string | null>(null)
|
||||
|
||||
// Get cached display name
|
||||
const cachedProjectName = useDisplayNamesStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
const effectiveCredentialId = credentialId || selectedCredentialId
|
||||
if (!effectiveCredentialId || !value) return null
|
||||
return state.cache.projects[`jira-${effectiveCredentialId}`]?.[value] || null
|
||||
},
|
||||
[credentialId, selectedCredentialId, value]
|
||||
)
|
||||
)
|
||||
|
||||
// Handle search with debounce
|
||||
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
// Clear any existing timeout
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current)
|
||||
}
|
||||
|
||||
// Set a new timeout
|
||||
searchTimeoutRef.current = setTimeout(() => {
|
||||
if (value.length >= 1) {
|
||||
fetchProjects(value)
|
||||
} else {
|
||||
fetchProjects() // Fetch all projects if no search term
|
||||
}
|
||||
}, 500) // 500ms debounce
|
||||
}
|
||||
|
||||
// Clean up the timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Determine the appropriate service ID based on provider and scopes
|
||||
const getServiceId = (): string => {
|
||||
if (serviceId) return serviceId
|
||||
return getServiceIdFromScopes(provider, requiredScopes)
|
||||
}
|
||||
|
||||
// Determine the appropriate provider ID based on service and scopes (stabilized)
|
||||
const providerId = useMemo(() => {
|
||||
const effectiveServiceId = getServiceId()
|
||||
return getProviderIdFromServiceId(effectiveServiceId)
|
||||
}, [serviceId, provider, requiredScopes])
|
||||
|
||||
// Fetch available credentials for this provider
|
||||
const fetchCredentials = useCallback(async () => {
|
||||
if (!providerId) return
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const response = await fetch(`/api/auth/oauth/credentials?provider=${providerId}`)
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setCredentials(data.credentials)
|
||||
// Do not auto-select credentials. Only use the credentialId provided by the parent.
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching credentials:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [providerId])
|
||||
|
||||
// Fetch detailed project information
|
||||
const fetchProjectInfo = useCallback(
|
||||
async (projectId: string) => {
|
||||
if (!selectedCredentialId || !domain || !projectId) return
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// Get the access token from the selected credential
|
||||
const tokenResponse = await fetch('/api/auth/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
credentialId: selectedCredentialId,
|
||||
workflowId,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorData = await tokenResponse.json()
|
||||
logger.error('Access token error:', errorData)
|
||||
setError('Authentication failed. Please reconnect your Jira account.')
|
||||
return
|
||||
}
|
||||
|
||||
const tokenData = await tokenResponse.json()
|
||||
const accessToken = tokenData.accessToken
|
||||
|
||||
if (!accessToken) {
|
||||
logger.error('No access token returned')
|
||||
setError('Authentication failed. Please reconnect your Jira account.')
|
||||
return
|
||||
}
|
||||
|
||||
// Use POST /api/tools/jira/projects to fetch a single project by id
|
||||
const response = await fetch(`/api/tools/jira/projects`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domain, accessToken, projectId, cloudId }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
logger.error('Jira API error:', errorData)
|
||||
throw new Error(errorData.error || 'Failed to fetch project details')
|
||||
}
|
||||
|
||||
const json = await response.json()
|
||||
const projectInfo = json?.project
|
||||
const newCloudId = json?.cloudId
|
||||
|
||||
if (newCloudId) {
|
||||
setCloudId(newCloudId)
|
||||
}
|
||||
|
||||
if (projectInfo) {
|
||||
setSelectedProject(projectInfo)
|
||||
onProjectInfoChange?.(projectInfo)
|
||||
} else {
|
||||
setSelectedProject(null)
|
||||
onProjectInfoChange?.(null)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching project details:', error)
|
||||
setError((error as Error).message)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
},
|
||||
[selectedCredentialId, domain, onProjectInfoChange, cloudId]
|
||||
)
|
||||
|
||||
// Fetch projects from Jira
|
||||
const fetchProjects = useCallback(
|
||||
async (searchQuery?: string) => {
|
||||
if (!selectedCredentialId || !domain) return
|
||||
|
||||
// Validate domain format
|
||||
const trimmedDomain = domain.trim().toLowerCase()
|
||||
if (!trimmedDomain.includes('.')) {
|
||||
setError(
|
||||
'Invalid domain format. Please provide the full domain (e.g., your-site.atlassian.net)'
|
||||
)
|
||||
setProjects([])
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// Get the access token from the selected credential
|
||||
const tokenResponse = await fetch('/api/auth/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
credentialId: selectedCredentialId,
|
||||
workflowId,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorData = await tokenResponse.json()
|
||||
logger.error('Access token error:', errorData)
|
||||
setError('Authentication failed. Please reconnect your Jira account.')
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const tokenData = await tokenResponse.json()
|
||||
const accessToken = tokenData.accessToken
|
||||
|
||||
if (!accessToken) {
|
||||
logger.error('No access token returned')
|
||||
setError('Authentication failed. Please reconnect your Jira account.')
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Build query parameters for the projects endpoint
|
||||
const queryParams = new URLSearchParams({
|
||||
domain,
|
||||
accessToken,
|
||||
...(searchQuery && { query: searchQuery }),
|
||||
...(cloudId && { cloudId }),
|
||||
})
|
||||
|
||||
// Use the GET endpoint for project search
|
||||
const response = await fetch(`/api/tools/jira/projects?${queryParams.toString()}`)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
logger.error('Jira API error:', errorData)
|
||||
throw new Error(errorData.error || 'Failed to fetch projects')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.cloudId) {
|
||||
setCloudId(data.cloudId)
|
||||
}
|
||||
|
||||
// Process the projects results
|
||||
const foundProjects = data.projects || []
|
||||
logger.info(`Received ${foundProjects.length} projects from API`)
|
||||
setProjects(foundProjects)
|
||||
|
||||
// Cache project names in display names store
|
||||
if (selectedCredentialId && foundProjects.length > 0) {
|
||||
const projectMap = foundProjects.reduce(
|
||||
(acc: Record<string, string>, proj: JiraProjectInfo) => {
|
||||
acc[proj.id] = proj.name
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
useDisplayNamesStore
|
||||
.getState()
|
||||
.setDisplayNames('projects', `jira-${selectedCredentialId}`, projectMap)
|
||||
}
|
||||
|
||||
// If we have a selected project ID, find the project info
|
||||
if (selectedProjectId) {
|
||||
const projectInfo = foundProjects.find(
|
||||
(project: JiraProjectInfo) => project.id === selectedProjectId
|
||||
)
|
||||
if (projectInfo) {
|
||||
setSelectedProject(projectInfo)
|
||||
onProjectInfoChange?.(projectInfo)
|
||||
} else if (!searchQuery && selectedProjectId) {
|
||||
// If we can't find the project in the list, try to fetch it directly
|
||||
fetchProjectInfo(selectedProjectId)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching projects:', error)
|
||||
setError((error as Error).message)
|
||||
setProjects([])
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
},
|
||||
[
|
||||
selectedCredentialId,
|
||||
domain,
|
||||
selectedProjectId,
|
||||
onProjectInfoChange,
|
||||
fetchProjectInfo,
|
||||
cloudId,
|
||||
]
|
||||
)
|
||||
|
||||
// Fetch credentials list when dropdown opens (for account switching UI), not on mount
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchCredentials()
|
||||
}
|
||||
}, [open, fetchCredentials])
|
||||
|
||||
// Keep local credential state in sync with persisted credential
|
||||
useEffect(() => {
|
||||
if (credentialId && credentialId !== selectedCredentialId) {
|
||||
setSelectedCredentialId(credentialId)
|
||||
}
|
||||
}, [credentialId, selectedCredentialId])
|
||||
|
||||
// Keep internal selectedProjectId in sync with the value prop
|
||||
useEffect(() => {
|
||||
if (value !== selectedProjectId) {
|
||||
setSelectedProjectId(value)
|
||||
}
|
||||
}, [value, selectedProjectId])
|
||||
|
||||
// Clear callback when value is cleared
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
setSelectedProject(null)
|
||||
onProjectInfoChange?.(null)
|
||||
}
|
||||
}, [value, onProjectInfoChange])
|
||||
|
||||
// Fetch project info on mount if we have a value but no selectedProject state
|
||||
useEffect(() => {
|
||||
if (value && selectedCredentialId && domain && !selectedProject) {
|
||||
fetchProjectInfo(value)
|
||||
}
|
||||
}, [value, selectedCredentialId, domain, selectedProject, fetchProjectInfo])
|
||||
|
||||
// Handle open change
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
setOpen(isOpen)
|
||||
// Only fetch projects when a credential is present; otherwise, do nothing
|
||||
if (isOpen && selectedCredentialId && domain && domain.includes('.')) {
|
||||
fetchProjects('')
|
||||
}
|
||||
}
|
||||
|
||||
// Handle project selection
|
||||
const handleSelectProject = (project: JiraProjectInfo) => {
|
||||
setSelectedProjectId(project.id)
|
||||
setSelectedProject(project)
|
||||
onChange(project.id, project)
|
||||
onProjectInfoChange?.(project)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
// Handle adding a new credential
|
||||
const handleAddCredential = () => {
|
||||
// Show the OAuth modal
|
||||
setShowOAuthModal(true)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
// Clear selection
|
||||
const handleClearSelection = () => {
|
||||
setSelectedProjectId('')
|
||||
setSelectedProject(null)
|
||||
setError(null)
|
||||
onChange('', undefined)
|
||||
onProjectInfoChange?.(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-2'>
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
role='combobox'
|
||||
aria-expanded={open}
|
||||
className='w-full justify-between'
|
||||
disabled={disabled || !domain || !selectedCredentialId || isForeignCredential}
|
||||
>
|
||||
{cachedProjectName ? (
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
<span className='truncate font-normal'>{cachedProjectName}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex items-center gap-2'>
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
<span className='text-muted-foreground'>{label}</span>
|
||||
</div>
|
||||
)}
|
||||
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
{!isForeignCredential && (
|
||||
<PopoverContent className='w-[300px] p-0' align='start'>
|
||||
{selectedCredentialId && credentials.length > 0 && (
|
||||
<div className='flex items-center justify-between border-b px-3 py-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
|
||||
'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
{credentials.length > 1 && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 px-2 text-xs'
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Switch
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Command>
|
||||
<CommandInput placeholder='Search projects...' onValueChange={handleSearch} />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{isLoading ? (
|
||||
<div className='flex items-center justify-center p-4'>
|
||||
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||
<span className='ml-2'>Loading projects...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='text-destructive text-sm'>{error}</p>
|
||||
</div>
|
||||
) : credentials.length === 0 ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No accounts connected.</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Connect a Jira account to continue.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No projects found.</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Try a different search or account.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CommandEmpty>
|
||||
|
||||
{/* Account selection - only show if we have multiple accounts */}
|
||||
{credentials.length > 1 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
Switch Account
|
||||
</div>
|
||||
{credentials.map((cred) => (
|
||||
<CommandItem
|
||||
key={cred.id}
|
||||
value={`account-${cred.id}`}
|
||||
onSelect={() => setSelectedCredentialId(cred.id)}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
<span className='font-normal'>{cred.name}</span>
|
||||
</div>
|
||||
{cred.id === selectedCredentialId && (
|
||||
<Check className='ml-auto h-4 w-4' />
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Projects list */}
|
||||
{projects.length > 0 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
Projects
|
||||
</div>
|
||||
{projects.map((project) => (
|
||||
<CommandItem
|
||||
key={project.id}
|
||||
value={`project-${project.id}-${project.name}`}
|
||||
onSelect={() => handleSelectProject(project)}
|
||||
>
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
{project.avatarUrl ? (
|
||||
<img
|
||||
src={project.avatarUrl}
|
||||
alt={project.name}
|
||||
className='h-4 w-4 rounded'
|
||||
/>
|
||||
) : (
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
)}
|
||||
<span className='truncate font-normal'>{project.name}</span>
|
||||
</div>
|
||||
{project.id === selectedProjectId && (
|
||||
<Check className='ml-auto h-4 w-4' />
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Connect account option - only show if no credentials */}
|
||||
{credentials.length === 0 && (
|
||||
<CommandGroup>
|
||||
<CommandItem onSelect={handleAddCredential}>
|
||||
<div className='flex items-center gap-2 text-foreground'>
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
<span>Connect Jira account</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
)}
|
||||
</Popover>
|
||||
|
||||
{/* Project preview */}
|
||||
{showPreview && selectedProject && (
|
||||
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
|
||||
<div className='absolute top-2 right-2'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-5 w-5 hover:bg-muted'
|
||||
onClick={handleClearSelection}
|
||||
>
|
||||
<X className='h-3 w-3' />
|
||||
</Button>
|
||||
</div>
|
||||
<div className='flex items-center gap-3 pr-4'>
|
||||
<div className='flex h-6 w-6 flex-shrink-0 items-center justify-center rounded bg-muted/20'>
|
||||
{selectedProject.avatarUrl ? (
|
||||
<img
|
||||
src={selectedProject.avatarUrl}
|
||||
alt={selectedProject.name}
|
||||
className='h-6 w-6 rounded'
|
||||
/>
|
||||
) : (
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
)}
|
||||
</div>
|
||||
<div className='min-w-0 flex-1 overflow-hidden'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<h4 className='truncate font-medium text-xs'>{selectedProject.name}</h4>
|
||||
<span className='whitespace-nowrap text-muted-foreground text-xs'>
|
||||
{selectedProject.key}
|
||||
</span>
|
||||
</div>
|
||||
{selectedProject.url ? (
|
||||
<a
|
||||
href={selectedProject.url}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='flex items-center gap-1 text-foreground text-xs hover:underline'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span>Open in Jira</span>
|
||||
<ExternalLink className='h-3 w-3' />
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showOAuthModal && (
|
||||
<OAuthRequiredModal
|
||||
isOpen={showOAuthModal}
|
||||
onClose={() => setShowOAuthModal(false)}
|
||||
provider={provider}
|
||||
toolName='Jira'
|
||||
requiredScopes={requiredScopes}
|
||||
serviceId={getServiceId()}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Check, ChevronDown, RefreshCw } from 'lucide-react'
|
||||
import { LinearIcon } from '@/components/icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { useDisplayNamesStore } from '@/stores/display-names/store'
|
||||
|
||||
export interface LinearProjectInfo {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface LinearProjectSelectorProps {
|
||||
value: string
|
||||
onChange: (projectId: string, projectInfo?: LinearProjectInfo) => void
|
||||
credential: string
|
||||
teamId: string
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
workflowId?: string
|
||||
}
|
||||
|
||||
export function LinearProjectSelector({
|
||||
value,
|
||||
onChange,
|
||||
credential,
|
||||
teamId,
|
||||
label = 'Select Linear project',
|
||||
disabled = false,
|
||||
workflowId,
|
||||
}: LinearProjectSelectorProps) {
|
||||
const [projects, setProjects] = useState<LinearProjectInfo[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
// Get cached display name
|
||||
const cachedProjectName = useDisplayNamesStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (!credential || !value) return null
|
||||
return state.cache.projects[`linear-${credential}`]?.[value] || null
|
||||
},
|
||||
[credential, value]
|
||||
)
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!credential || !teamId) return
|
||||
const controller = new AbortController()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
fetch('/api/tools/linear/projects', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ credential, teamId, workflowId }),
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text()
|
||||
throw new Error(`HTTP error! status: ${res.status} - ${errorText}`)
|
||||
}
|
||||
return res.json()
|
||||
})
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
setError(data.error)
|
||||
setProjects([])
|
||||
} else {
|
||||
setProjects(data.projects)
|
||||
|
||||
// Cache project names in display names store
|
||||
if (credential && data.projects) {
|
||||
const projectMap = data.projects.reduce(
|
||||
(acc: Record<string, string>, proj: LinearProjectInfo) => {
|
||||
acc[proj.id] = proj.name
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
useDisplayNamesStore
|
||||
.getState()
|
||||
.setDisplayNames('projects', `linear-${credential}`, projectMap)
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.name === 'AbortError') return
|
||||
setError(err.message)
|
||||
setProjects([])
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
return () => controller.abort()
|
||||
}, [credential, teamId, value, workflowId])
|
||||
|
||||
const handleSelectProject = (project: LinearProjectInfo) => {
|
||||
onChange(project.id, project)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
setOpen(isOpen)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
role='combobox'
|
||||
aria-expanded={open}
|
||||
className='w-full justify-between'
|
||||
disabled={disabled || !credential || !teamId}
|
||||
>
|
||||
{cachedProjectName ? (
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
<LinearIcon className='h-4 w-4' />
|
||||
<span className='truncate font-normal'>{cachedProjectName}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex items-center gap-2'>
|
||||
<LinearIcon className='h-4 w-4' />
|
||||
<span className='text-muted-foreground'>{label}</span>
|
||||
</div>
|
||||
)}
|
||||
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-[300px] p-0' align='start'>
|
||||
<Command>
|
||||
<CommandInput placeholder='Search projects...' />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{loading ? (
|
||||
<div className='flex items-center justify-center p-4'>
|
||||
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||
<span className='ml-2'>Loading projects...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='text-destructive text-sm'>{error}</p>
|
||||
</div>
|
||||
) : !credential || !teamId ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>Missing credentials or team</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Please configure Linear credentials and select a team.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No projects found</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
No projects available for the selected team.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CommandEmpty>
|
||||
|
||||
{projects.length > 0 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
Projects
|
||||
</div>
|
||||
{projects.map((project) => (
|
||||
<CommandItem
|
||||
key={project.id}
|
||||
value={`project-${project.id}-${project.name}`}
|
||||
onSelect={() => handleSelectProject(project)}
|
||||
className='cursor-pointer'
|
||||
>
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
<LinearIcon className='h-4 w-4' />
|
||||
<span className='truncate font-normal'>{project.name}</span>
|
||||
</div>
|
||||
{project.id === value && <Check className='ml-auto h-4 w-4' />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Check, ChevronDown, RefreshCw } from 'lucide-react'
|
||||
import { LinearIcon } from '@/components/icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { useDisplayNamesStore } from '@/stores/display-names/store'
|
||||
|
||||
export interface LinearTeamInfo {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface LinearTeamSelectorProps {
|
||||
value: string
|
||||
onChange: (teamId: string, teamInfo?: LinearTeamInfo) => void
|
||||
credential: string
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
workflowId?: string
|
||||
showPreview?: boolean
|
||||
}
|
||||
|
||||
export function LinearTeamSelector({
|
||||
value,
|
||||
onChange,
|
||||
credential,
|
||||
label = 'Select Linear team',
|
||||
disabled = false,
|
||||
workflowId,
|
||||
}: LinearTeamSelectorProps) {
|
||||
const [teams, setTeams] = useState<LinearTeamInfo[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
// Get cached display name
|
||||
const cachedTeamName = useDisplayNamesStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (!credential || !value) return null
|
||||
return state.cache.projects[`linear-${credential}`]?.[value] || null
|
||||
},
|
||||
[credential, value]
|
||||
)
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!credential) return
|
||||
const controller = new AbortController()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
fetch('/api/tools/linear/teams', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ credential, workflowId }),
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`)
|
||||
return res.json()
|
||||
})
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
setError(data.error)
|
||||
setTeams([])
|
||||
} else {
|
||||
setTeams(data.teams)
|
||||
|
||||
// Cache team names in display names store
|
||||
if (credential && data.teams) {
|
||||
const teamMap = data.teams.reduce(
|
||||
(acc: Record<string, string>, team: LinearTeamInfo) => {
|
||||
acc[team.id] = team.name
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
useDisplayNamesStore
|
||||
.getState()
|
||||
.setDisplayNames('projects', `linear-${credential}`, teamMap)
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.name === 'AbortError') return
|
||||
setError(err.message)
|
||||
setTeams([])
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
return () => controller.abort()
|
||||
}, [credential, value, workflowId])
|
||||
|
||||
const handleSelectTeam = (team: LinearTeamInfo) => {
|
||||
onChange(team.id, team)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
setOpen(isOpen)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
role='combobox'
|
||||
aria-expanded={open}
|
||||
className='w-full justify-between'
|
||||
disabled={disabled || !credential}
|
||||
>
|
||||
{cachedTeamName ? (
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
<LinearIcon className='h-4 w-4' />
|
||||
<span className='truncate font-normal'>{cachedTeamName}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex items-center gap-2'>
|
||||
<LinearIcon className='h-4 w-4' />
|
||||
<span className='text-muted-foreground'>{label}</span>
|
||||
</div>
|
||||
)}
|
||||
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-[300px] p-0' align='start'>
|
||||
<Command>
|
||||
<CommandInput placeholder='Search teams...' />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{loading ? (
|
||||
<div className='flex items-center justify-center p-4'>
|
||||
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||
<span className='ml-2'>Loading teams...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='text-destructive text-sm'>{error}</p>
|
||||
</div>
|
||||
) : !credential ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>Missing credentials</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Please configure Linear credentials.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No teams found</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
No teams available for this Linear account.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CommandEmpty>
|
||||
|
||||
{teams.length > 0 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>Teams</div>
|
||||
{teams.map((team) => (
|
||||
<CommandItem
|
||||
key={team.id}
|
||||
value={`team-${team.id}-${team.name}`}
|
||||
onSelect={() => handleSelectTeam(team)}
|
||||
className='cursor-pointer'
|
||||
>
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
<LinearIcon className='h-4 w-4' />
|
||||
<span className='truncate font-normal'>{team.name}</span>
|
||||
</div>
|
||||
{team.id === value && <Check className='ml-auto h-4 w-4' />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -1,23 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Tooltip } from '@/components/emcn'
|
||||
import {
|
||||
type JiraProjectInfo,
|
||||
JiraProjectSelector,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/project-selector/components/jira-project-selector'
|
||||
import {
|
||||
type LinearProjectInfo,
|
||||
LinearProjectSelector,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/project-selector/components/linear-project-selector'
|
||||
import {
|
||||
type LinearTeamInfo,
|
||||
LinearTeamSelector,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/project-selector/components/linear-team-selector'
|
||||
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
|
||||
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-depends-on-gate'
|
||||
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-foreign-credential'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { resolveSelectorForSubBlock } from '@/hooks/selectors/resolution'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
@@ -41,10 +32,10 @@ export function ProjectSelectorInput({
|
||||
previewContextValues,
|
||||
}: ProjectSelectorInputProps) {
|
||||
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
|
||||
const params = useParams()
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string>('')
|
||||
const [_projectInfo, setProjectInfo] = useState<any | null>(null)
|
||||
// Use the proper hook to get the current value and setter
|
||||
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
|
||||
const [storeValue] = useSubBlockValue(blockId, subBlock.id)
|
||||
const [connectedCredentialFromStore] = useSubBlockValue(blockId, 'credential')
|
||||
const [linearTeamIdFromStore] = useSubBlockValue(blockId, 'teamId')
|
||||
const [jiraDomainFromStore] = useSubBlockValue(blockId, 'domain')
|
||||
@@ -60,6 +51,7 @@ export function ProjectSelectorInput({
|
||||
(connectedCredential as string) || ''
|
||||
)
|
||||
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId) as string | null
|
||||
const workflowIdFromUrl = (params?.workflowId as string) || activeWorkflowId || ''
|
||||
const { finalDisabled } = useDependsOnGate(blockId, subBlock, {
|
||||
disabled,
|
||||
isPreview,
|
||||
@@ -87,91 +79,58 @@ export function ProjectSelectorInput({
|
||||
}
|
||||
}, [isPreview, previewValue, storeValue])
|
||||
|
||||
// Handle project selection
|
||||
const handleProjectChange = (
|
||||
projectId: string,
|
||||
info?: JiraProjectInfo | LinearTeamInfo | LinearProjectInfo
|
||||
) => {
|
||||
setSelectedProjectId(projectId)
|
||||
setProjectInfo(info || null)
|
||||
setStoreValue(projectId)
|
||||
const selectorResolution = useMemo(() => {
|
||||
return resolveSelectorForSubBlock(subBlock, {
|
||||
workflowId: workflowIdFromUrl || undefined,
|
||||
credentialId: (isLinear ? linearCredential : jiraCredential) as string | undefined,
|
||||
domain,
|
||||
teamId: (linearTeamId as string) || undefined,
|
||||
})
|
||||
}, [
|
||||
subBlock,
|
||||
workflowIdFromUrl,
|
||||
isLinear,
|
||||
linearCredential,
|
||||
jiraCredential,
|
||||
domain,
|
||||
linearTeamId,
|
||||
])
|
||||
|
||||
onProjectSelect?.(projectId)
|
||||
const missingCredential = !selectorResolution?.context.credentialId
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
setSelectedProjectId(value)
|
||||
onProjectSelect?.(value)
|
||||
}
|
||||
|
||||
// Discord no longer uses a server selector; fall through to other providers
|
||||
|
||||
// Render Linear team/project selector if provider is linear
|
||||
if (isLinear) {
|
||||
return (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div className='w-full'>
|
||||
{subBlock.id === 'teamId' ? (
|
||||
<LinearTeamSelector
|
||||
value={selectedProjectId}
|
||||
onChange={(teamId: string, teamInfo?: LinearTeamInfo) => {
|
||||
handleProjectChange(teamId, teamInfo)
|
||||
}}
|
||||
credential={(linearCredential as string) || ''}
|
||||
label={subBlock.placeholder || 'Select Linear team'}
|
||||
disabled={finalDisabled}
|
||||
showPreview={true}
|
||||
workflowId={activeWorkflowId || ''}
|
||||
/>
|
||||
) : (
|
||||
(() => {
|
||||
const credential = (linearCredential as string) || ''
|
||||
const teamId = (linearTeamId as string) || ''
|
||||
const isDisabled = finalDisabled
|
||||
return (
|
||||
<LinearProjectSelector
|
||||
value={selectedProjectId}
|
||||
onChange={(projectId: string, projectInfo?: LinearProjectInfo) => {
|
||||
handleProjectChange(projectId, projectInfo)
|
||||
}}
|
||||
credential={credential}
|
||||
teamId={teamId}
|
||||
label={subBlock.placeholder || 'Select Linear project'}
|
||||
disabled={isDisabled}
|
||||
workflowId={activeWorkflowId || ''}
|
||||
/>
|
||||
)
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
{!(linearCredential as string) && (
|
||||
<Tooltip.Content side='top'>
|
||||
<p>Please select a Linear account first</p>
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
)
|
||||
}
|
||||
|
||||
// Default to Jira project selector
|
||||
return (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div className='w-full'>
|
||||
<JiraProjectSelector
|
||||
value={selectedProjectId}
|
||||
onChange={handleProjectChange}
|
||||
domain={domain}
|
||||
provider='jira'
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
serviceId={subBlock.serviceId}
|
||||
label={subBlock.placeholder || 'Select Jira project'}
|
||||
disabled={finalDisabled}
|
||||
showPreview={true}
|
||||
onProjectInfoChange={setProjectInfo}
|
||||
credentialId={(jiraCredential as string) || ''}
|
||||
isForeignCredential={isForeignCredential}
|
||||
workflowId={activeWorkflowId || ''}
|
||||
/>
|
||||
{selectorResolution?.key ? (
|
||||
<SelectorCombobox
|
||||
blockId={blockId}
|
||||
subBlock={subBlock}
|
||||
selectorKey={selectorResolution.key}
|
||||
selectorContext={selectorResolution.context}
|
||||
disabled={finalDisabled || isForeignCredential || missingCredential}
|
||||
isPreview={isPreview}
|
||||
previewValue={previewValue ?? null}
|
||||
placeholder={subBlock.placeholder || 'Select project'}
|
||||
onOptionChange={handleChange}
|
||||
/>
|
||||
) : (
|
||||
<div className='w-full rounded border border-dashed p-4 text-center text-muted-foreground text-sm'>
|
||||
Project selector not supported for provider: {subBlock.provider || 'unknown'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
{missingCredential && (
|
||||
<Tooltip.Content side='top'>
|
||||
<p>Please select an account first</p>
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import type React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Combobox as EditableCombobox } from '@/components/emcn/components'
|
||||
import { SubBlockInputController } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/sub-block-input-controller'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types'
|
||||
import {
|
||||
useSelectorOptionDetail,
|
||||
useSelectorOptionMap,
|
||||
useSelectorOptions,
|
||||
} from '@/hooks/selectors/use-selector-query'
|
||||
|
||||
interface SelectorComboboxProps {
|
||||
blockId: string
|
||||
subBlock: SubBlockConfig
|
||||
selectorKey: SelectorKey
|
||||
selectorContext: SelectorContext
|
||||
disabled?: boolean
|
||||
isPreview?: boolean
|
||||
previewValue?: string | null
|
||||
placeholder?: string
|
||||
readOnly?: boolean
|
||||
onOptionChange?: (value: string) => void
|
||||
allowSearch?: boolean
|
||||
}
|
||||
|
||||
export function SelectorCombobox({
|
||||
blockId,
|
||||
subBlock,
|
||||
selectorKey,
|
||||
selectorContext,
|
||||
disabled,
|
||||
isPreview,
|
||||
previewValue,
|
||||
placeholder,
|
||||
readOnly,
|
||||
onOptionChange,
|
||||
allowSearch = true,
|
||||
}: SelectorComboboxProps) {
|
||||
const [storeValueRaw, setStoreValue] = useSubBlockValue<string | null | undefined>(
|
||||
blockId,
|
||||
subBlock.id
|
||||
)
|
||||
const storeValue = storeValueRaw ?? undefined
|
||||
const previewedValue = previewValue ?? undefined
|
||||
const activeValue: string | undefined = isPreview ? previewedValue : storeValue
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const {
|
||||
data: options = [],
|
||||
isLoading,
|
||||
error,
|
||||
} = useSelectorOptions(selectorKey, {
|
||||
context: selectorContext,
|
||||
search: allowSearch ? searchTerm : undefined,
|
||||
})
|
||||
const { data: detailOption } = useSelectorOptionDetail(selectorKey, {
|
||||
context: selectorContext,
|
||||
detailId: activeValue,
|
||||
})
|
||||
const optionMap = useSelectorOptionMap(options, detailOption ?? undefined)
|
||||
const selectedLabel = activeValue ? (optionMap.get(activeValue)?.label ?? activeValue) : ''
|
||||
const [inputValue, setInputValue] = useState(selectedLabel)
|
||||
const previousActiveValue = useRef<string | undefined>(activeValue)
|
||||
|
||||
useEffect(() => {
|
||||
if (previousActiveValue.current !== activeValue) {
|
||||
previousActiveValue.current = activeValue
|
||||
setIsEditing(false)
|
||||
}
|
||||
}, [activeValue])
|
||||
|
||||
useEffect(() => {
|
||||
if (!allowSearch) return
|
||||
if (!isEditing) {
|
||||
setInputValue(selectedLabel)
|
||||
}
|
||||
}, [selectedLabel, allowSearch, isEditing])
|
||||
|
||||
const comboboxOptions = useMemo(
|
||||
() =>
|
||||
Array.from(optionMap.values()).map((option) => ({
|
||||
label: option.label,
|
||||
value: option.id,
|
||||
})),
|
||||
[optionMap]
|
||||
)
|
||||
|
||||
const handleSelection = useCallback(
|
||||
(value: string) => {
|
||||
if (readOnly || disabled) return
|
||||
setStoreValue(value)
|
||||
setIsEditing(false)
|
||||
onOptionChange?.(value)
|
||||
},
|
||||
[setStoreValue, onOptionChange, readOnly, disabled]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='w-full'>
|
||||
<SubBlockInputController
|
||||
blockId={blockId}
|
||||
subBlockId={subBlock.id}
|
||||
config={subBlock}
|
||||
value={activeValue ?? ''}
|
||||
disabled={disabled || readOnly}
|
||||
isPreview={isPreview}
|
||||
>
|
||||
{({ ref, onDrop, onDragOver }) => (
|
||||
<EditableCombobox
|
||||
options={comboboxOptions}
|
||||
value={allowSearch ? inputValue : selectedLabel}
|
||||
selectedValue={activeValue ?? ''}
|
||||
onChange={(newValue) => {
|
||||
const matched = optionMap.get(newValue)
|
||||
if (matched) {
|
||||
setInputValue(matched.label)
|
||||
setIsEditing(false)
|
||||
handleSelection(matched.id)
|
||||
return
|
||||
}
|
||||
if (allowSearch) {
|
||||
setInputValue(newValue)
|
||||
setIsEditing(true)
|
||||
setSearchTerm(newValue)
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder || subBlock.placeholder || 'Select an option'}
|
||||
disabled={disabled || readOnly}
|
||||
editable={allowSearch}
|
||||
filterOptions={allowSearch}
|
||||
inputRef={ref as React.RefObject<HTMLInputElement>}
|
||||
inputProps={{
|
||||
onDrop: onDrop as (e: React.DragEvent<HTMLInputElement>) => void,
|
||||
onDragOver: onDragOver as (e: React.DragEvent<HTMLInputElement>) => void,
|
||||
}}
|
||||
isLoading={isLoading}
|
||||
error={error instanceof Error ? error.message : null}
|
||||
/>
|
||||
)}
|
||||
</SubBlockInputController>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,588 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { McpTransport } from '@/lib/mcp/types'
|
||||
import {
|
||||
checkEnvVarTrigger,
|
||||
EnvVarDropdown,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/env-var-dropdown'
|
||||
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/formatted-text'
|
||||
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
|
||||
import { useCreateMcpServer } from '@/hooks/queries/mcp'
|
||||
import { useMcpServerTest } from '@/hooks/use-mcp-server-test'
|
||||
|
||||
const logger = createLogger('McpServerModal')
|
||||
|
||||
interface McpServerModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onServerCreated?: () => void
|
||||
blockId: string
|
||||
}
|
||||
|
||||
interface McpServerFormData {
|
||||
name: string
|
||||
transport: McpTransport
|
||||
url?: string
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
|
||||
export function McpServerModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
onServerCreated,
|
||||
blockId,
|
||||
}: McpServerModalProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const [formData, setFormData] = useState<McpServerFormData>({
|
||||
name: '',
|
||||
transport: 'streamable-http',
|
||||
url: '',
|
||||
headers: { '': '' },
|
||||
})
|
||||
const createServerMutation = useCreateMcpServer()
|
||||
const [localError, setLocalError] = useState<string | null>(null)
|
||||
|
||||
// MCP server testing
|
||||
const { testResult, isTestingConnection, testConnection, clearTestResult } = useMcpServerTest()
|
||||
|
||||
// Environment variable dropdown state
|
||||
const [showEnvVars, setShowEnvVars] = useState(false)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [cursorPosition, setCursorPosition] = useState(0)
|
||||
const [activeInputField, setActiveInputField] = useState<
|
||||
'url' | 'header-key' | 'header-value' | null
|
||||
>(null)
|
||||
const [activeHeaderIndex, setActiveHeaderIndex] = useState<number | null>(null)
|
||||
const urlInputRef = useRef<HTMLInputElement>(null)
|
||||
const [urlScrollLeft, setUrlScrollLeft] = useState(0)
|
||||
const [headerScrollLeft, setHeaderScrollLeft] = useState<Record<string, number>>({})
|
||||
|
||||
const error = localError || createServerMutation.error?.message
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
name: '',
|
||||
transport: 'streamable-http',
|
||||
url: '',
|
||||
headers: { '': '' },
|
||||
})
|
||||
setLocalError(null)
|
||||
createServerMutation.reset()
|
||||
setShowEnvVars(false)
|
||||
setActiveInputField(null)
|
||||
setActiveHeaderIndex(null)
|
||||
clearTestResult()
|
||||
}
|
||||
|
||||
// Handle environment variable selection
|
||||
const handleEnvVarSelect = useCallback(
|
||||
(newValue: string) => {
|
||||
if (activeInputField === 'url') {
|
||||
setFormData((prev) => ({ ...prev, url: newValue }))
|
||||
} else if (activeInputField === 'header-key' && activeHeaderIndex !== null) {
|
||||
const headerEntries = Object.entries(formData.headers || {})
|
||||
const [oldKey, value] = headerEntries[activeHeaderIndex]
|
||||
const newHeaders = { ...formData.headers }
|
||||
delete newHeaders[oldKey]
|
||||
newHeaders[newValue.replace(/[{}]/g, '')] = value
|
||||
setFormData((prev) => ({ ...prev, headers: newHeaders }))
|
||||
} else if (activeInputField === 'header-value' && activeHeaderIndex !== null) {
|
||||
const headerEntries = Object.entries(formData.headers || {})
|
||||
const [key] = headerEntries[activeHeaderIndex]
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
headers: { ...prev.headers, [key]: newValue },
|
||||
}))
|
||||
}
|
||||
setShowEnvVars(false)
|
||||
setActiveInputField(null)
|
||||
setActiveHeaderIndex(null)
|
||||
},
|
||||
[activeInputField, activeHeaderIndex, formData.headers]
|
||||
)
|
||||
|
||||
// Handle input change with env var detection
|
||||
const handleInputChange = useCallback(
|
||||
(field: 'url' | 'header-key' | 'header-value', value: string, headerIndex?: number) => {
|
||||
const input = document.activeElement as HTMLInputElement
|
||||
const pos = input?.selectionStart || 0
|
||||
|
||||
setCursorPosition(pos)
|
||||
|
||||
// Clear test result when any field changes
|
||||
if (testResult) {
|
||||
clearTestResult()
|
||||
}
|
||||
|
||||
// Check if we should show the environment variables dropdown
|
||||
const envVarTrigger = checkEnvVarTrigger(value, pos)
|
||||
setShowEnvVars(envVarTrigger.show)
|
||||
setSearchTerm(envVarTrigger.show ? envVarTrigger.searchTerm : '')
|
||||
|
||||
if (envVarTrigger.show) {
|
||||
setActiveInputField(field)
|
||||
setActiveHeaderIndex(headerIndex ?? null)
|
||||
} else {
|
||||
setActiveInputField(null)
|
||||
setActiveHeaderIndex(null)
|
||||
}
|
||||
|
||||
// Update form data
|
||||
if (field === 'url') {
|
||||
setFormData((prev) => ({ ...prev, url: value }))
|
||||
} else if (field === 'header-key' && headerIndex !== undefined) {
|
||||
const headerEntries = Object.entries(formData.headers || {})
|
||||
const [oldKey, headerValue] = headerEntries[headerIndex]
|
||||
const newHeaders = { ...formData.headers }
|
||||
delete newHeaders[oldKey]
|
||||
newHeaders[value] = headerValue
|
||||
|
||||
// Add a new empty header row if this is the last row and both key and value have content
|
||||
const isLastRow = headerIndex === headerEntries.length - 1
|
||||
const hasContent = value.trim() !== '' && headerValue.trim() !== ''
|
||||
if (isLastRow && hasContent) {
|
||||
newHeaders[''] = ''
|
||||
}
|
||||
|
||||
setFormData((prev) => ({ ...prev, headers: newHeaders }))
|
||||
} else if (field === 'header-value' && headerIndex !== undefined) {
|
||||
const headerEntries = Object.entries(formData.headers || {})
|
||||
const [key] = headerEntries[headerIndex]
|
||||
const newHeaders = { ...formData.headers, [key]: value }
|
||||
|
||||
// Add a new empty header row if this is the last row and both key and value have content
|
||||
const isLastRow = headerIndex === headerEntries.length - 1
|
||||
const hasContent = key.trim() !== '' && value.trim() !== ''
|
||||
if (isLastRow && hasContent) {
|
||||
newHeaders[''] = ''
|
||||
}
|
||||
|
||||
setFormData((prev) => ({ ...prev, headers: newHeaders }))
|
||||
}
|
||||
},
|
||||
[formData.headers, testResult, clearTestResult]
|
||||
)
|
||||
|
||||
const handleTestConnection = useCallback(async () => {
|
||||
if (!formData.name.trim() || !formData.url?.trim()) return
|
||||
|
||||
await testConnection({
|
||||
name: formData.name,
|
||||
transport: formData.transport,
|
||||
url: formData.url,
|
||||
headers: formData.headers,
|
||||
timeout: 30000,
|
||||
workspaceId,
|
||||
})
|
||||
}, [formData, testConnection, workspaceId])
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!formData.name.trim()) {
|
||||
setLocalError('Server name is required')
|
||||
return
|
||||
}
|
||||
|
||||
if (!formData.url?.trim()) {
|
||||
setLocalError('Server URL is required for HTTP/SSE transport')
|
||||
return
|
||||
}
|
||||
|
||||
setLocalError(null)
|
||||
createServerMutation.reset()
|
||||
|
||||
try {
|
||||
// If no test has been done, test first
|
||||
if (!testResult) {
|
||||
const result = await testConnection({
|
||||
name: formData.name,
|
||||
transport: formData.transport,
|
||||
url: formData.url,
|
||||
headers: formData.headers,
|
||||
timeout: 30000,
|
||||
workspaceId,
|
||||
})
|
||||
|
||||
// If test fails, don't proceed
|
||||
if (!result.success) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// If we have a failed test result, don't proceed
|
||||
if (testResult && !testResult.success) {
|
||||
return
|
||||
}
|
||||
|
||||
// Filter out empty headers
|
||||
const cleanHeaders = Object.fromEntries(
|
||||
Object.entries(formData.headers || {}).filter(
|
||||
([key, value]) => key.trim() !== '' && value.trim() !== ''
|
||||
)
|
||||
)
|
||||
|
||||
await createServerMutation.mutateAsync({
|
||||
workspaceId,
|
||||
config: {
|
||||
name: formData.name.trim(),
|
||||
transport: formData.transport,
|
||||
url: formData.url,
|
||||
timeout: 30000,
|
||||
headers: cleanHeaders,
|
||||
enabled: true,
|
||||
},
|
||||
})
|
||||
|
||||
logger.info(`Added MCP server: ${formData.name}`)
|
||||
|
||||
// Close modal and reset form immediately after successful creation
|
||||
resetForm()
|
||||
onOpenChange(false)
|
||||
onServerCreated?.()
|
||||
} catch (error) {
|
||||
logger.error('Failed to add MCP server:', error)
|
||||
setLocalError(error instanceof Error ? error.message : 'Failed to add MCP server')
|
||||
}
|
||||
}, [
|
||||
formData,
|
||||
testResult,
|
||||
testConnection,
|
||||
onOpenChange,
|
||||
onServerCreated,
|
||||
createServerMutation,
|
||||
workspaceId,
|
||||
])
|
||||
|
||||
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='sm:max-w-[600px]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add MCP Server</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure a new Model Context Protocol server to extend your workflow capabilities.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='space-y-4 py-4'>
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<div>
|
||||
<Label htmlFor='server-name'>Server Name</Label>
|
||||
<Input
|
||||
id='server-name'
|
||||
placeholder='e.g., My MCP Server'
|
||||
value={formData.name}
|
||||
onChange={(e) => {
|
||||
if (testResult) clearTestResult()
|
||||
setFormData((prev) => ({ ...prev, name: e.target.value }))
|
||||
}}
|
||||
className='h-9'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor='transport'>Transport Type</Label>
|
||||
<Select
|
||||
value={formData.transport}
|
||||
onValueChange={(value: 'http' | 'sse' | 'streamable-http') => {
|
||||
if (testResult) clearTestResult()
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
transport: value as McpTransport,
|
||||
}))
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className='h-9'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='streamable-http'>Streamable HTTP</SelectItem>
|
||||
<SelectItem value='http'>HTTP</SelectItem>
|
||||
<SelectItem value='sse'>Server-Sent Events</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='relative'>
|
||||
<Label htmlFor='server-url'>Server URL</Label>
|
||||
<div className='relative'>
|
||||
<Input
|
||||
ref={urlInputRef}
|
||||
id='server-url'
|
||||
placeholder='https://mcp.server.dev/{{YOUR_API_KEY}}/sse'
|
||||
value={formData.url}
|
||||
onChange={(e) => handleInputChange('url', e.target.value)}
|
||||
onScroll={(e) => {
|
||||
const scrollLeft = e.currentTarget.scrollLeft
|
||||
setUrlScrollLeft(scrollLeft)
|
||||
}}
|
||||
onInput={(e) => {
|
||||
const scrollLeft = e.currentTarget.scrollLeft
|
||||
setUrlScrollLeft(scrollLeft)
|
||||
}}
|
||||
className='h-9 text-transparent caret-foreground placeholder:text-muted-foreground/50'
|
||||
/>
|
||||
|
||||
{/* Overlay for styled text display */}
|
||||
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden px-3 text-sm'>
|
||||
<div
|
||||
className='whitespace-nowrap'
|
||||
style={{ transform: `translateX(-${urlScrollLeft}px)` }}
|
||||
>
|
||||
{formatDisplayText(formData.url || '', {
|
||||
accessiblePrefixes,
|
||||
highlightAll: !accessiblePrefixes,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Environment Variables Dropdown */}
|
||||
{showEnvVars && activeInputField === 'url' && (
|
||||
<EnvVarDropdown
|
||||
visible={showEnvVars}
|
||||
onSelect={handleEnvVarSelect}
|
||||
searchTerm={searchTerm}
|
||||
inputValue={formData.url || ''}
|
||||
cursorPosition={cursorPosition}
|
||||
workspaceId={workspaceId}
|
||||
onClose={() => {
|
||||
setShowEnvVars(false)
|
||||
setActiveInputField(null)
|
||||
}}
|
||||
className='w-full'
|
||||
maxHeight='250px'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Headers (Optional)</Label>
|
||||
<div className='space-y-2'>
|
||||
{Object.entries(formData.headers || {}).map(([key, value], index) => (
|
||||
<div key={index} className='relative flex gap-2'>
|
||||
{/* Header Name Input */}
|
||||
<div className='relative flex-1'>
|
||||
<Input
|
||||
placeholder='Name'
|
||||
value={key}
|
||||
onChange={(e) => handleInputChange('header-key', e.target.value, index)}
|
||||
onScroll={(e) => {
|
||||
const scrollLeft = e.currentTarget.scrollLeft
|
||||
setHeaderScrollLeft((prev) => ({ ...prev, [`key-${index}`]: scrollLeft }))
|
||||
}}
|
||||
onInput={(e) => {
|
||||
const scrollLeft = e.currentTarget.scrollLeft
|
||||
setHeaderScrollLeft((prev) => ({ ...prev, [`key-${index}`]: scrollLeft }))
|
||||
}}
|
||||
className='h-9 text-transparent caret-foreground placeholder:text-muted-foreground/50'
|
||||
/>
|
||||
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden px-3 text-sm'>
|
||||
<div
|
||||
className='whitespace-nowrap'
|
||||
style={{
|
||||
transform: `translateX(-${headerScrollLeft[`key-${index}`] || 0}px)`,
|
||||
}}
|
||||
>
|
||||
{formatDisplayText(key || '', {
|
||||
accessiblePrefixes,
|
||||
highlightAll: !accessiblePrefixes,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Header Value Input */}
|
||||
<div className='relative flex-1'>
|
||||
<Input
|
||||
placeholder='Value'
|
||||
value={value}
|
||||
onChange={(e) => handleInputChange('header-value', e.target.value, index)}
|
||||
onScroll={(e) => {
|
||||
const scrollLeft = e.currentTarget.scrollLeft
|
||||
setHeaderScrollLeft((prev) => ({ ...prev, [`value-${index}`]: scrollLeft }))
|
||||
}}
|
||||
onInput={(e) => {
|
||||
const scrollLeft = e.currentTarget.scrollLeft
|
||||
setHeaderScrollLeft((prev) => ({ ...prev, [`value-${index}`]: scrollLeft }))
|
||||
}}
|
||||
className='h-9 text-transparent caret-foreground placeholder:text-muted-foreground/50'
|
||||
/>
|
||||
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden px-3 text-sm'>
|
||||
<div
|
||||
className='whitespace-nowrap'
|
||||
style={{
|
||||
transform: `translateX(-${headerScrollLeft[`value-${index}`] || 0}px)`,
|
||||
}}
|
||||
>
|
||||
{formatDisplayText(value || '', {
|
||||
accessiblePrefixes,
|
||||
highlightAll: !accessiblePrefixes,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
onClick={() => {
|
||||
const headerEntries = Object.entries(formData.headers || {})
|
||||
if (headerEntries.length === 1) {
|
||||
// If this is the only header, just clear it instead of deleting
|
||||
setFormData((prev) => ({ ...prev, headers: { '': '' } }))
|
||||
} else {
|
||||
// Delete this header
|
||||
const newHeaders = { ...formData.headers }
|
||||
delete newHeaders[key]
|
||||
setFormData((prev) => ({ ...prev, headers: newHeaders }))
|
||||
}
|
||||
}}
|
||||
className='h-9 w-9 p-0 text-muted-foreground hover:text-foreground'
|
||||
>
|
||||
<X className='h-3 w-3' />
|
||||
</Button>
|
||||
|
||||
{/* Environment Variables Dropdown for Header Key */}
|
||||
{showEnvVars &&
|
||||
activeInputField === 'header-key' &&
|
||||
activeHeaderIndex === index && (
|
||||
<EnvVarDropdown
|
||||
visible={showEnvVars}
|
||||
onSelect={handleEnvVarSelect}
|
||||
searchTerm={searchTerm}
|
||||
inputValue={key}
|
||||
cursorPosition={cursorPosition}
|
||||
workspaceId={workspaceId}
|
||||
onClose={() => {
|
||||
setShowEnvVars(false)
|
||||
setActiveInputField(null)
|
||||
setActiveHeaderIndex(null)
|
||||
}}
|
||||
className='w-full'
|
||||
maxHeight='150px'
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
zIndex: 9999,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Environment Variables Dropdown for Header Value */}
|
||||
{showEnvVars &&
|
||||
activeInputField === 'header-value' &&
|
||||
activeHeaderIndex === index && (
|
||||
<EnvVarDropdown
|
||||
visible={showEnvVars}
|
||||
onSelect={handleEnvVarSelect}
|
||||
searchTerm={searchTerm}
|
||||
inputValue={value}
|
||||
cursorPosition={cursorPosition}
|
||||
workspaceId={workspaceId}
|
||||
onClose={() => {
|
||||
setShowEnvVars(false)
|
||||
setActiveInputField(null)
|
||||
setActiveHeaderIndex(null)
|
||||
}}
|
||||
className='w-full'
|
||||
maxHeight='250px'
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
right: 0,
|
||||
zIndex: 9999,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className='rounded-md bg-destructive/10 px-3 py-2 text-destructive text-sm'>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Test Connection and Actions */}
|
||||
<div className='border-t pt-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={handleTestConnection}
|
||||
disabled={isTestingConnection || !formData.name.trim() || !formData.url?.trim()}
|
||||
className='text-muted-foreground hover:text-foreground'
|
||||
>
|
||||
{isTestingConnection ? 'Testing...' : 'Test Connection'}
|
||||
</Button>
|
||||
{testResult?.success && (
|
||||
<span className='text-green-600 text-xs'>✓ Connected</span>
|
||||
)}
|
||||
</div>
|
||||
{testResult && !testResult.success && (
|
||||
<div className='rounded border border-red-200 bg-red-50 px-2 py-1.5 text-red-600 text-xs dark:border-red-800 dark:bg-red-950/20'>
|
||||
<div className='font-medium'>Connection failed</div>
|
||||
<div className='text-red-500 dark:text-red-400'>
|
||||
{testResult.error || testResult.message}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
resetForm()
|
||||
onOpenChange(false)
|
||||
}}
|
||||
disabled={createServerMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
onClick={handleSubmit}
|
||||
disabled={
|
||||
createServerMutation.isPending || !formData.name.trim() || !formData.url?.trim()
|
||||
}
|
||||
>
|
||||
{createServerMutation.isPending ? 'Adding...' : 'Add Server'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Check, ChevronDown, ExternalLink, Plus, RefreshCw } from 'lucide-react'
|
||||
import { Button } from '@/components/emcn/components/button/button'
|
||||
import {
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
type Credential,
|
||||
getCanonicalScopesForProvider,
|
||||
getProviderIdFromServiceId,
|
||||
OAUTH_PROVIDERS,
|
||||
@@ -20,8 +19,8 @@ import {
|
||||
parseProvider,
|
||||
} from '@/lib/oauth'
|
||||
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
|
||||
import { useOAuthCredentialDetail, useOAuthCredentials } from '@/hooks/queries/oauth-credentials'
|
||||
import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status'
|
||||
import { useDisplayNamesStore } from '@/stores/display-names/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
const logger = createLogger('ToolCredentialSelector')
|
||||
@@ -70,8 +69,6 @@ export function ToolCredentialSelector({
|
||||
disabled = false,
|
||||
}: ToolCredentialSelectorProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [credentials, setCredentials] = useState<Credential[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [showOAuthModal, setShowOAuthModal] = useState(false)
|
||||
const [selectedId, setSelectedId] = useState('')
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
@@ -80,80 +77,43 @@ export function ToolCredentialSelector({
|
||||
setSelectedId(value)
|
||||
}, [value])
|
||||
|
||||
const fetchCredentials = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const response = await fetch(`/api/auth/oauth/credentials?provider=${provider}`)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setCredentials(data.credentials || [])
|
||||
const {
|
||||
data: fetchedCredentials = [],
|
||||
isFetching: credentialsLoading,
|
||||
refetch: refetchCredentials,
|
||||
} = useOAuthCredentials(provider, true)
|
||||
|
||||
// Cache credential names for block previews
|
||||
if (provider) {
|
||||
const credentialMap = (data.credentials || []).reduce(
|
||||
(acc: Record<string, string>, cred: Credential) => {
|
||||
acc[cred.id] = cred.name
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
useDisplayNamesStore.getState().setDisplayNames('credentials', provider, credentialMap)
|
||||
}
|
||||
const shouldFetchDetail =
|
||||
Boolean(value) &&
|
||||
!fetchedCredentials.some((cred) => cred.id === value) &&
|
||||
Boolean(activeWorkflowId)
|
||||
|
||||
if (
|
||||
value &&
|
||||
!(data.credentials || []).some((cred: Credential) => cred.id === value) &&
|
||||
activeWorkflowId
|
||||
) {
|
||||
try {
|
||||
const metaResp = await fetch(
|
||||
`/api/auth/oauth/credentials?credentialId=${value}&workflowId=${activeWorkflowId}`
|
||||
)
|
||||
if (metaResp.ok) {
|
||||
const meta = await metaResp.json()
|
||||
if (meta.credentials?.length) {
|
||||
const combinedCredentials = [meta.credentials[0], ...(data.credentials || [])]
|
||||
setCredentials(combinedCredentials)
|
||||
const { data: collaboratorCredentials = [], isFetching: collaboratorLoading } =
|
||||
useOAuthCredentialDetail(
|
||||
shouldFetchDetail ? value : undefined,
|
||||
activeWorkflowId || undefined,
|
||||
shouldFetchDetail
|
||||
)
|
||||
|
||||
const credentialMap = combinedCredentials.reduce(
|
||||
(acc: Record<string, string>, cred: Credential) => {
|
||||
acc[cred.id] = cred.name
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
useDisplayNamesStore
|
||||
.getState()
|
||||
.setDisplayNames('credentials', provider, credentialMap)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.error('Error fetching credentials:', { error: await response.text() })
|
||||
setCredentials([])
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching credentials:', { error })
|
||||
setCredentials([])
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
const credentials = useMemo(() => {
|
||||
if (collaboratorCredentials.length === 0) {
|
||||
return fetchedCredentials
|
||||
}
|
||||
}, [provider, value, onChange])
|
||||
|
||||
// Fetch credentials on initial mount only
|
||||
useEffect(() => {
|
||||
fetchCredentials()
|
||||
// This effect should only run once on mount
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
const collaborator = collaboratorCredentials[0]
|
||||
if (!collaborator) {
|
||||
return fetchedCredentials
|
||||
}
|
||||
const alreadyIncluded = fetchedCredentials.some((cred) => cred.id === collaborator.id)
|
||||
if (alreadyIncluded) {
|
||||
return fetchedCredentials
|
||||
}
|
||||
return [collaborator, ...fetchedCredentials]
|
||||
}, [fetchedCredentials, collaboratorCredentials])
|
||||
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
fetchCredentials()
|
||||
void refetchCredentials()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,7 +122,7 @@ export function ToolCredentialSelector({
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
}
|
||||
}, [fetchCredentials])
|
||||
}, [refetchCredentials])
|
||||
|
||||
const handleSelect = (credentialId: string) => {
|
||||
setSelectedId(credentialId)
|
||||
@@ -172,13 +132,13 @@ export function ToolCredentialSelector({
|
||||
|
||||
const handleOAuthClose = () => {
|
||||
setShowOAuthModal(false)
|
||||
fetchCredentials()
|
||||
void refetchCredentials()
|
||||
}
|
||||
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
setOpen(isOpen)
|
||||
if (isOpen) {
|
||||
fetchCredentials()
|
||||
void refetchCredentials()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,7 +150,8 @@ export function ToolCredentialSelector({
|
||||
const missingRequiredScopes = hasSelection
|
||||
? getMissingRequiredScopes(selectedCredential, requiredScopes || [])
|
||||
: []
|
||||
const needsUpdate = hasSelection && missingRequiredScopes.length > 0 && !disabled && !isLoading
|
||||
const needsUpdate =
|
||||
hasSelection && missingRequiredScopes.length > 0 && !disabled && !credentialsLoading
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -224,7 +185,7 @@ export function ToolCredentialSelector({
|
||||
<Command>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{isLoading ? (
|
||||
{credentialsLoading || collaboratorLoading ? (
|
||||
<div className='flex items-center justify-center p-4'>
|
||||
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||
<span className='ml-2'>Loading...</span>
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Tooltip } from '@/components/emcn'
|
||||
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
|
||||
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-depends-on-gate'
|
||||
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-foreign-credential'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
interface FileSelectorInputProps {
|
||||
blockId: string
|
||||
subBlock: SubBlockConfig
|
||||
disabled: boolean
|
||||
isPreview?: boolean
|
||||
previewValue?: any | null
|
||||
previewContextValues?: Record<string, any>
|
||||
}
|
||||
|
||||
export function FileSelectorInput({
|
||||
blockId,
|
||||
subBlock,
|
||||
disabled,
|
||||
isPreview = false,
|
||||
previewValue,
|
||||
previewContextValues,
|
||||
}: FileSelectorInputProps) {
|
||||
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
const params = useParams()
|
||||
const workflowIdFromUrl = (params?.workflowId as string) || activeWorkflowId || ''
|
||||
|
||||
const { finalDisabled } = useDependsOnGate(blockId, subBlock, {
|
||||
disabled,
|
||||
isPreview,
|
||||
previewContextValues,
|
||||
})
|
||||
|
||||
const [connectedCredentialFromStore] = useSubBlockValue(blockId, 'credential')
|
||||
const [domainValueFromStore] = useSubBlockValue(blockId, 'domain')
|
||||
const [projectIdValueFromStore] = useSubBlockValue(blockId, 'projectId')
|
||||
const [planIdValueFromStore] = useSubBlockValue(blockId, 'planId')
|
||||
const [teamIdValueFromStore] = useSubBlockValue(blockId, 'teamId')
|
||||
|
||||
const connectedCredential = previewContextValues?.credential ?? connectedCredentialFromStore
|
||||
const domainValue = previewContextValues?.domain ?? domainValueFromStore
|
||||
const projectIdValue = previewContextValues?.projectId ?? projectIdValueFromStore
|
||||
const planIdValue = previewContextValues?.planId ?? planIdValueFromStore
|
||||
const teamIdValue = previewContextValues?.teamId ?? teamIdValueFromStore
|
||||
|
||||
const normalizedCredentialId =
|
||||
typeof connectedCredential === 'string'
|
||||
? connectedCredential
|
||||
: typeof connectedCredential === 'object' && connectedCredential !== null
|
||||
? ((connectedCredential as Record<string, any>).id ?? '')
|
||||
: ''
|
||||
|
||||
const { isForeignCredential } = useForeignCredential(
|
||||
subBlock.provider || subBlock.serviceId || 'google-drive',
|
||||
normalizedCredentialId
|
||||
)
|
||||
|
||||
const selectorResolution = useMemo(() => {
|
||||
return resolveSelector({
|
||||
provider: subBlock.provider || '',
|
||||
serviceId: subBlock.serviceId,
|
||||
mimeType: subBlock.mimeType,
|
||||
credentialId: normalizedCredentialId,
|
||||
workflowId: workflowIdFromUrl,
|
||||
domain: (domainValue as string) || '',
|
||||
projectId: (projectIdValue as string) || '',
|
||||
planId: (planIdValue as string) || '',
|
||||
teamId: (teamIdValue as string) || '',
|
||||
})
|
||||
}, [
|
||||
subBlock.provider,
|
||||
subBlock.serviceId,
|
||||
subBlock.mimeType,
|
||||
normalizedCredentialId,
|
||||
workflowIdFromUrl,
|
||||
domainValue,
|
||||
projectIdValue,
|
||||
planIdValue,
|
||||
teamIdValue,
|
||||
])
|
||||
|
||||
const missingCredential = !normalizedCredentialId
|
||||
const missingDomain =
|
||||
selectorResolution.key &&
|
||||
(selectorResolution.key === 'confluence.pages' || selectorResolution.key === 'jira.issues') &&
|
||||
!selectorResolution.context.domain
|
||||
const missingProject =
|
||||
selectorResolution.key === 'jira.issues' &&
|
||||
subBlock.dependsOn?.includes('projectId') &&
|
||||
!selectorResolution.context.projectId
|
||||
const missingPlan =
|
||||
selectorResolution.key === 'microsoft.planner' && !selectorResolution.context.planId
|
||||
|
||||
const disabledReason =
|
||||
finalDisabled ||
|
||||
isForeignCredential ||
|
||||
missingCredential ||
|
||||
missingDomain ||
|
||||
missingProject ||
|
||||
missingPlan ||
|
||||
selectorResolution.key === null
|
||||
|
||||
if (selectorResolution.key === null) {
|
||||
return (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div className='w-full rounded border border-dashed p-4 text-center text-muted-foreground text-sm'>
|
||||
File selector not supported for provider: {subBlock.provider || subBlock.serviceId}
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
<p>This file selector is not implemented for {subBlock.provider || subBlock.serviceId}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SelectorCombobox
|
||||
blockId={blockId}
|
||||
subBlock={subBlock}
|
||||
selectorKey={selectorResolution.key}
|
||||
selectorContext={selectorResolution.context}
|
||||
disabled={disabledReason}
|
||||
isPreview={isPreview}
|
||||
previewValue={previewValue ?? null}
|
||||
placeholder={subBlock.placeholder || 'Select resource'}
|
||||
allowSearch={selectorResolution.allowSearch}
|
||||
onOptionChange={(value) => {
|
||||
if (!isPreview) {
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, value)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface SelectorParams {
|
||||
provider: string
|
||||
serviceId?: string
|
||||
mimeType?: string
|
||||
credentialId: string
|
||||
workflowId: string
|
||||
domain?: string
|
||||
projectId?: string
|
||||
planId?: string
|
||||
teamId?: string
|
||||
}
|
||||
|
||||
function resolveSelector(params: SelectorParams): {
|
||||
key: SelectorKey | null
|
||||
context: SelectorContext
|
||||
allowSearch: boolean
|
||||
} {
|
||||
const baseContext: SelectorContext = {
|
||||
credentialId: params.credentialId,
|
||||
workflowId: params.workflowId,
|
||||
domain: params.domain,
|
||||
projectId: params.projectId,
|
||||
planId: params.planId,
|
||||
teamId: params.teamId,
|
||||
mimeType: params.mimeType,
|
||||
}
|
||||
|
||||
switch (params.provider) {
|
||||
case 'google-calendar':
|
||||
return { key: 'google.calendar', context: baseContext, allowSearch: false }
|
||||
case 'confluence':
|
||||
return { key: 'confluence.pages', context: baseContext, allowSearch: true }
|
||||
case 'jira':
|
||||
return { key: 'jira.issues', context: baseContext, allowSearch: true }
|
||||
case 'microsoft-teams':
|
||||
return { key: 'microsoft.teams', context: baseContext, allowSearch: true }
|
||||
case 'wealthbox':
|
||||
return { key: 'wealthbox.contacts', context: baseContext, allowSearch: true }
|
||||
case 'microsoft-planner':
|
||||
return { key: 'microsoft.planner', context: baseContext, allowSearch: true }
|
||||
case 'microsoft-excel':
|
||||
return { key: 'microsoft.excel', context: baseContext, allowSearch: true }
|
||||
case 'microsoft-word':
|
||||
return { key: 'microsoft.word', context: baseContext, allowSearch: true }
|
||||
case 'google-drive':
|
||||
return { key: 'google.drive', context: baseContext, allowSearch: true }
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
if (params.serviceId === 'onedrive') {
|
||||
const key: SelectorKey = params.mimeType === 'file' ? 'onedrive.files' : 'onedrive.folders'
|
||||
return { key, context: baseContext, allowSearch: true }
|
||||
}
|
||||
|
||||
if (params.serviceId === 'sharepoint') {
|
||||
return { key: 'sharepoint.sites', context: baseContext, allowSearch: true }
|
||||
}
|
||||
|
||||
if (params.serviceId === 'google-drive') {
|
||||
return { key: 'google.drive', context: baseContext, allowSearch: true }
|
||||
}
|
||||
|
||||
return { key: null, context: baseContext, allowSearch: true }
|
||||
}
|
||||
@@ -13,9 +13,10 @@ import {
|
||||
useBlockDimensions,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions'
|
||||
import { SELECTOR_TYPES_HYDRATION_REQUIRED, type SubBlockConfig } from '@/blocks/types'
|
||||
import { useCredentialName } from '@/hooks/queries/oauth-credentials'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useCredentialDisplay } from '@/hooks/use-credential-display'
|
||||
import { useDisplayName } from '@/hooks/use-display-name'
|
||||
import { useKnowledgeBaseName } from '@/hooks/use-knowledge-base-name'
|
||||
import { useSelectorDisplayName } from '@/hooks/use-selector-display-name'
|
||||
import { useVariablesStore } from '@/stores/panel/variables/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
@@ -230,9 +231,12 @@ const SubBlockRow = ({
|
||||
}, {})
|
||||
}, [getStringValue, subBlock?.dependsOn])
|
||||
|
||||
const { displayName: credentialName } = useCredentialDisplay(
|
||||
subBlock?.type === 'oauth-input' && typeof rawValue === 'string' ? rawValue : undefined,
|
||||
subBlock?.provider
|
||||
const credentialSourceId =
|
||||
subBlock?.type === 'oauth-input' && typeof rawValue === 'string' ? rawValue : undefined
|
||||
const { displayName: credentialName } = useCredentialName(
|
||||
credentialSourceId,
|
||||
subBlock?.provider,
|
||||
workflowId
|
||||
)
|
||||
|
||||
const credentialId = dependencyValues.credential
|
||||
@@ -253,17 +257,35 @@ const SubBlockRow = ({
|
||||
return typeof option === 'string' ? option : option.label
|
||||
}, [subBlock, rawValue])
|
||||
|
||||
const genericDisplayName = useDisplayName(subBlock, rawValue, {
|
||||
workspaceId,
|
||||
provider: subBlock?.provider,
|
||||
const domainValue = getStringValue('domain')
|
||||
const teamIdValue = getStringValue('teamId')
|
||||
const projectIdValue = getStringValue('projectId')
|
||||
const planIdValue = getStringValue('planId')
|
||||
|
||||
const { displayName: selectorDisplayName } = useSelectorDisplayName({
|
||||
subBlock,
|
||||
value: rawValue,
|
||||
workflowId,
|
||||
credentialId: typeof credentialId === 'string' ? credentialId : undefined,
|
||||
knowledgeBaseId: typeof knowledgeBaseId === 'string' ? knowledgeBaseId : undefined,
|
||||
domain: getStringValue('domain'),
|
||||
teamId: getStringValue('teamId'),
|
||||
projectId: getStringValue('projectId'),
|
||||
planId: getStringValue('planId'),
|
||||
domain: domainValue,
|
||||
teamId: teamIdValue,
|
||||
projectId: projectIdValue,
|
||||
planId: planIdValue,
|
||||
})
|
||||
|
||||
const knowledgeBaseDisplayName = useKnowledgeBaseName(
|
||||
subBlock?.type === 'knowledge-base-selector' && typeof rawValue === 'string'
|
||||
? rawValue
|
||||
: undefined
|
||||
)
|
||||
|
||||
const workflowMap = useWorkflowRegistry((state) => state.workflows)
|
||||
const workflowSelectionName =
|
||||
subBlock?.id === 'workflowId' && typeof rawValue === 'string'
|
||||
? (workflowMap[rawValue]?.name ?? null)
|
||||
: null
|
||||
|
||||
// Subscribe to variables store to reactively update when variables change
|
||||
const allVariables = useVariablesStore((state) => state.variables)
|
||||
|
||||
@@ -300,7 +322,12 @@ const SubBlockRow = ({
|
||||
|
||||
const isSelectorType = subBlock?.type && SELECTOR_TYPES_HYDRATION_REQUIRED.includes(subBlock.type)
|
||||
const hydratedName =
|
||||
credentialName || dropdownLabel || variablesDisplayValue || genericDisplayName
|
||||
credentialName ||
|
||||
dropdownLabel ||
|
||||
variablesDisplayValue ||
|
||||
knowledgeBaseDisplayName ||
|
||||
workflowSelectionName ||
|
||||
selectorDisplayName
|
||||
const displayValue = maskedValue || hydratedName || (isSelectorType && value ? '-' : value)
|
||||
|
||||
return (
|
||||
|
||||
@@ -434,6 +434,7 @@ const WorkflowContent = React.memo(() => {
|
||||
activeElement?.hasAttribute('contenteditable')
|
||||
|
||||
if (isEditableElement) {
|
||||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1214,16 +1215,29 @@ const WorkflowContent = React.memo(() => {
|
||||
|
||||
// Initialize workflow when it exists in registry and isn't active
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const currentId = params.workflowId as string
|
||||
if (!currentId || !workflows[currentId]) return
|
||||
|
||||
// Wait for registry to be ready to prevent race conditions
|
||||
// Don't proceed if: no workflowId, registry is loading, or workflow not in registry
|
||||
if (!currentId || isLoading || !workflows[currentId]) return
|
||||
|
||||
if (activeWorkflowId !== currentId) {
|
||||
// Clear diff and set as active
|
||||
const { clearDiff } = useWorkflowDiffStore.getState()
|
||||
clearDiff()
|
||||
setActiveWorkflow(currentId)
|
||||
|
||||
setActiveWorkflow(currentId).catch((error) => {
|
||||
if (!cancelled) {
|
||||
logger.error(`Failed to set active workflow ${currentId}:`, error)
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [params.workflowId, workflows, activeWorkflowId, setActiveWorkflow])
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [params.workflowId, workflows, activeWorkflowId, setActiveWorkflow, isLoading])
|
||||
|
||||
// Track when workflow is ready for rendering
|
||||
useEffect(() => {
|
||||
@@ -1233,11 +1247,15 @@ const WorkflowContent = React.memo(() => {
|
||||
// 1. We have an active workflow that matches the URL
|
||||
// 2. The workflow exists in the registry
|
||||
// 3. Workflows are not currently loading
|
||||
// 4. The workflow store has been initialized (lastSaved exists means state was loaded)
|
||||
const shouldBeReady =
|
||||
activeWorkflowId === currentId && Boolean(workflows[currentId]) && !isLoading
|
||||
activeWorkflowId === currentId &&
|
||||
Boolean(workflows[currentId]) &&
|
||||
!isLoading &&
|
||||
lastSaved !== undefined
|
||||
|
||||
setIsWorkflowReady(shouldBeReady)
|
||||
}, [activeWorkflowId, params.workflowId, workflows, isLoading])
|
||||
}, [activeWorkflowId, params.workflowId, workflows, isLoading, lastSaved])
|
||||
|
||||
// Preload workspace environment - React Query handles caching automatically
|
||||
useWorkspaceEnvironment(workspaceId)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useUsageLimits } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/hooks'
|
||||
import { useUpdateUsageLimit } from '@/hooks/queries/subscription'
|
||||
|
||||
const logger = createLogger('UsageLimit')
|
||||
|
||||
@@ -42,20 +43,22 @@ export const UsageLimit = forwardRef<UsageLimitRef, UsageLimitProps>(
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Use centralized usage limits hook
|
||||
const { updateLimit, isUpdating } = useUsageLimits({
|
||||
const { updateLimit, isUpdating: isOrgUpdating } = useUsageLimits({
|
||||
context,
|
||||
organizationId,
|
||||
autoRefresh: false, // Don't auto-refresh, we receive values via props
|
||||
})
|
||||
|
||||
const updateUsageLimitMutation = useUpdateUsageLimit()
|
||||
const isUpdating =
|
||||
context === 'organization' ? isOrgUpdating : updateUsageLimitMutation.isPending
|
||||
|
||||
const handleStartEdit = () => {
|
||||
if (!canEdit) return
|
||||
setIsEditing(true)
|
||||
setInputValue(currentLimit.toString())
|
||||
}
|
||||
|
||||
// Expose startEdit method through ref
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
@@ -68,7 +71,6 @@ export const UsageLimit = forwardRef<UsageLimitRef, UsageLimitProps>(
|
||||
setInputValue(currentLimit.toString())
|
||||
}, [currentLimit])
|
||||
|
||||
// Focus input when entering edit mode
|
||||
useEffect(() => {
|
||||
if (isEditing && inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
@@ -76,7 +78,6 @@ export const UsageLimit = forwardRef<UsageLimitRef, UsageLimitProps>(
|
||||
}
|
||||
}, [isEditing])
|
||||
|
||||
// Clear error after 2 seconds
|
||||
useEffect(() => {
|
||||
if (hasError) {
|
||||
const timer = setTimeout(() => {
|
||||
@@ -96,11 +97,9 @@ export const UsageLimit = forwardRef<UsageLimitRef, UsageLimitProps>(
|
||||
return
|
||||
}
|
||||
|
||||
// Check if new limit is below current usage
|
||||
if (newLimit < currentUsage) {
|
||||
setHasError(true)
|
||||
setErrorType('belowUsage')
|
||||
// Don't reset input value - let user see what they typed
|
||||
return
|
||||
}
|
||||
|
||||
@@ -109,20 +108,43 @@ export const UsageLimit = forwardRef<UsageLimitRef, UsageLimitProps>(
|
||||
return
|
||||
}
|
||||
|
||||
// Use the centralized hook to update the limit
|
||||
const result = await updateLimit(newLimit)
|
||||
try {
|
||||
if (context === 'organization') {
|
||||
const result = await updateLimit(newLimit)
|
||||
|
||||
if (result.success) {
|
||||
setInputValue(newLimit.toString())
|
||||
onLimitUpdated?.(newLimit)
|
||||
setIsEditing(false)
|
||||
setErrorType(null)
|
||||
setHasError(false)
|
||||
} else {
|
||||
logger.error('Failed to update usage limit', { error: result.error })
|
||||
|
||||
if (result.error?.includes('below current usage')) {
|
||||
setErrorType('belowUsage')
|
||||
} else {
|
||||
setErrorType('general')
|
||||
}
|
||||
|
||||
setHasError(true)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await updateUsageLimitMutation.mutateAsync({ limit: newLimit })
|
||||
|
||||
if (result.success) {
|
||||
setInputValue(newLimit.toString())
|
||||
onLimitUpdated?.(newLimit)
|
||||
setIsEditing(false)
|
||||
setErrorType(null)
|
||||
setHasError(false)
|
||||
} else {
|
||||
logger.error('Failed to update usage limit', { error: result.error })
|
||||
} catch (err) {
|
||||
logger.error('Failed to update usage limit', { error: err })
|
||||
|
||||
// Check if the error is about being below current usage
|
||||
if (result.error?.includes('below current usage')) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
if (message.includes('below current usage')) {
|
||||
setErrorType('belowUsage')
|
||||
} else {
|
||||
setErrorType('general')
|
||||
@@ -161,7 +183,6 @@ export const UsageLimit = forwardRef<UsageLimitRef, UsageLimitProps>(
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={(e) => {
|
||||
// Don't submit if clicking on the button (it will handle submission)
|
||||
const relatedTarget = e.relatedTarget as HTMLElement
|
||||
if (relatedTarget?.closest('button')) {
|
||||
return
|
||||
|
||||
@@ -169,7 +169,6 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
const canManageWorkspaceKeys = userPermissions.canAdmin
|
||||
const logger = createLogger('Subscription')
|
||||
|
||||
// React Query hooks for data fetching
|
||||
const { data: subscriptionData, isLoading: isSubscriptionLoading } = useSubscriptionData()
|
||||
const { data: usageLimitResponse, isLoading: isUsageLimitLoading } = useUsageLimitData()
|
||||
const { data: workspaceData, isLoading: isWorkspaceLoading } = useWorkspaceSettings(workspaceId)
|
||||
@@ -179,7 +178,6 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
const activeOrganization = orgsData?.activeOrganization
|
||||
const activeOrgId = activeOrganization?.id
|
||||
|
||||
// Fetch organization billing data with React Query
|
||||
const { data: organizationBillingData, isLoading: isOrgBillingLoading } = useOrganizationBilling(
|
||||
activeOrgId || ''
|
||||
)
|
||||
@@ -187,10 +185,8 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
const [upgradeError, setUpgradeError] = useState<'pro' | 'team' | null>(null)
|
||||
const usageLimitRef = useRef<UsageLimitRef | null>(null)
|
||||
|
||||
// Combine all loading states
|
||||
const isLoading = isSubscriptionLoading || isUsageLimitLoading || isWorkspaceLoading
|
||||
|
||||
// Extract subscription status from subscriptionData.data
|
||||
const subscription = {
|
||||
isFree: subscriptionData?.data?.plan === 'free' || !subscriptionData?.data?.plan,
|
||||
isPro: subscriptionData?.data?.plan === 'pro',
|
||||
@@ -205,28 +201,23 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
seats: subscriptionData?.data?.seats || 1,
|
||||
}
|
||||
|
||||
// Extract usage data from subscriptionData.data.usage (same source as panel usage indicator)
|
||||
const usage = {
|
||||
current: subscriptionData?.data?.usage?.current || 0,
|
||||
limit: subscriptionData?.data?.usage?.limit || 0,
|
||||
percentUsed: subscriptionData?.data?.usage?.percentUsed || 0,
|
||||
}
|
||||
|
||||
// Extract usage limit metadata from usageLimitResponse.data
|
||||
const usageLimitData = {
|
||||
currentLimit: usageLimitResponse?.data?.currentLimit || 0,
|
||||
minimumLimit: usageLimitResponse?.data?.minimumLimit || (subscription.isPro ? 20 : 40),
|
||||
}
|
||||
|
||||
// Extract billing status
|
||||
const billingStatus = subscriptionData?.data?.billingBlocked ? 'blocked' : 'ok'
|
||||
|
||||
// Extract workspace settings
|
||||
const billedAccountUserId = workspaceData?.settings?.workspace?.billedAccountUserId ?? null
|
||||
const workspaceAdmins =
|
||||
workspaceData?.permissions?.users?.filter((user: any) => user.permissionType === 'admin') || []
|
||||
|
||||
// Update workspace settings handler
|
||||
const updateWorkspaceSettings = async (updates: { billedAccountUserId?: string }) => {
|
||||
if (!workspaceId) return
|
||||
try {
|
||||
@@ -240,7 +231,6 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-clear upgrade error
|
||||
useEffect(() => {
|
||||
if (upgradeError) {
|
||||
const timer = setTimeout(() => {
|
||||
@@ -250,11 +240,9 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
}
|
||||
}, [upgradeError])
|
||||
|
||||
// User role and permissions
|
||||
const userRole = getUserRole(activeOrganization, session?.user?.email)
|
||||
const isTeamAdmin = ['owner', 'admin'].includes(userRole)
|
||||
|
||||
// Get permissions based on subscription state and user role
|
||||
const permissions = getSubscriptionPermissions(
|
||||
{
|
||||
isFree: subscription.isFree,
|
||||
@@ -271,7 +259,6 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
}
|
||||
)
|
||||
|
||||
// Get visible plans based on current subscription
|
||||
const visiblePlans = getVisiblePlans(
|
||||
{
|
||||
isFree: subscription.isFree,
|
||||
@@ -459,8 +446,8 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
}
|
||||
context={subscription.isTeam && isTeamAdmin ? 'organization' : 'user'}
|
||||
organizationId={subscription.isTeam && isTeamAdmin ? activeOrgId : undefined}
|
||||
onLimitUpdated={async () => {
|
||||
// React Query will automatically refetch when the mutation completes
|
||||
onLimitUpdated={() => {
|
||||
logger.info('Usage limit updated')
|
||||
}}
|
||||
/>
|
||||
) : undefined
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
getSubscriptionStatus,
|
||||
getUsage,
|
||||
} from '@/lib/subscription/helpers'
|
||||
import { isUsageAtLimit, USAGE_PILL_COLORS } from '@/lib/subscription/usage-visualization'
|
||||
import { useSubscriptionData } from '@/hooks/queries/subscription'
|
||||
import { MIN_SIDEBAR_WIDTH, useSidebarStore } from '@/stores/sidebar/store'
|
||||
|
||||
@@ -116,11 +117,12 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
|
||||
|
||||
/**
|
||||
* Calculate which pills should be filled based on usage percentage.
|
||||
* Uses Math.ceil heuristic with dynamic pill count (6-8).
|
||||
* This ensures consistent calculation logic while maintaining responsive pill count.
|
||||
* Uses a percentage-based heuristic with dynamic pill count (6-8).
|
||||
* The warning/limit (red) state is derived from shared usage visualization utilities
|
||||
* so it is consistent with other parts of the app (e.g. UsageHeader).
|
||||
*/
|
||||
const filledPillsCount = Math.ceil((progressPercentage / 100) * pillCount)
|
||||
const isAlmostOut = filledPillsCount === pillCount
|
||||
const isAtLimit = isUsageAtLimit(progressPercentage)
|
||||
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const [wavePosition, setWavePosition] = useState<number | null>(null)
|
||||
@@ -286,17 +288,17 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
|
||||
const isFilled = i < filledPillsCount
|
||||
|
||||
const baseColor = isFilled
|
||||
? isBlocked || isAlmostOut
|
||||
? '#ef4444'
|
||||
: '#34B5FF'
|
||||
: '#414141'
|
||||
? isBlocked || isAtLimit
|
||||
? USAGE_PILL_COLORS.AT_LIMIT
|
||||
: USAGE_PILL_COLORS.FILLED
|
||||
: USAGE_PILL_COLORS.UNFILLED
|
||||
|
||||
let backgroundColor = baseColor
|
||||
let backgroundImage: string | undefined
|
||||
|
||||
if (isHovered && wavePosition !== null && pillCount > 0 && subscription.isFree) {
|
||||
const grayColor = '#414141'
|
||||
const activeColor = isAlmostOut ? '#ef4444' : '#34B5FF'
|
||||
const grayColor = USAGE_PILL_COLORS.UNFILLED
|
||||
const activeColor = isAtLimit ? USAGE_PILL_COLORS.AT_LIMIT : USAGE_PILL_COLORS.FILLED
|
||||
|
||||
/**
|
||||
* Single-pass wave: travel from {@link startAnimationIndex} to the end
|
||||
|
||||
@@ -38,12 +38,8 @@ export default function WorkflowsPage() {
|
||||
|
||||
// If we have valid workspace workflows, redirect to the first one
|
||||
if (workspaceWorkflows.length > 0) {
|
||||
// Ensure the workflow is set as active before redirecting
|
||||
// This prevents the empty canvas issue on first login
|
||||
const firstWorkflowId = workspaceWorkflows[0]
|
||||
setActiveWorkflow(firstWorkflowId).then(() => {
|
||||
router.replace(`/workspace/${workspaceId}/w/${firstWorkflowId}`)
|
||||
})
|
||||
router.replace(`/workspace/${workspaceId}/w/${firstWorkflowId}`)
|
||||
}
|
||||
}, [isLoading, workflows, workspaceId, router, setActiveWorkflow, isError])
|
||||
|
||||
|
||||
@@ -112,7 +112,9 @@ export async function executeWebhookJob(payload: WebhookExecutionPayload) {
|
||||
|
||||
const idempotencyKey = IdempotencyService.createWebhookIdempotencyKey(
|
||||
payload.webhookId,
|
||||
payload.headers
|
||||
payload.headers,
|
||||
payload.body,
|
||||
payload.provider
|
||||
)
|
||||
|
||||
const runOperation = async () => {
|
||||
|
||||
@@ -55,7 +55,7 @@ export class TriggerBlockHandler implements BlockHandler {
|
||||
}
|
||||
}
|
||||
|
||||
if (provider === 'microsoftteams') {
|
||||
if (provider === 'microsoft-teams') {
|
||||
const providerData = (starterOutput as any)[provider] || webhookData[provider] || {}
|
||||
const payloadSource = providerData?.message?.raw || webhookData.payload || {}
|
||||
return {
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
} from '@/executor/types'
|
||||
import { buildAPIUrl, buildAuthHeaders } from '@/executor/utils/http'
|
||||
import { parseJSON } from '@/executor/utils/json'
|
||||
import { lazyCleanupInputMapping } from '@/executor/utils/lazy-cleanup'
|
||||
import { Serializer } from '@/serializer'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
@@ -86,7 +87,15 @@ export class WorkflowBlockHandler implements BlockHandler {
|
||||
const normalized = parseJSON(inputs.inputMapping, inputs.inputMapping)
|
||||
|
||||
if (normalized && typeof normalized === 'object' && !Array.isArray(normalized)) {
|
||||
childWorkflowInput = normalized as Record<string, any>
|
||||
// Perform lazy cleanup: remove orphaned fields from inputMapping
|
||||
// that no longer exist in the child workflow's inputFormat
|
||||
const cleanedMapping = await lazyCleanupInputMapping(
|
||||
ctx.workflowId || 'unknown',
|
||||
block.id,
|
||||
normalized,
|
||||
childWorkflow.rawBlocks || {}
|
||||
)
|
||||
childWorkflowInput = cleanedMapping as Record<string, any>
|
||||
} else {
|
||||
childWorkflowInput = {}
|
||||
}
|
||||
@@ -209,6 +218,7 @@ export class WorkflowBlockHandler implements BlockHandler {
|
||||
name: workflowData.name,
|
||||
serializedState: serializedWorkflow,
|
||||
variables: workflowVariables,
|
||||
rawBlocks: workflowState.blocks,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,6 +291,7 @@ export class WorkflowBlockHandler implements BlockHandler {
|
||||
name: wfData?.name || DEFAULTS.WORKFLOW_NAME,
|
||||
serializedState: serializedWorkflow,
|
||||
variables: workflowVariables,
|
||||
rawBlocks: deployedState.blocks,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
163
apps/sim/executor/utils/lazy-cleanup.ts
Normal file
163
apps/sim/executor/utils/lazy-cleanup.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflowBlocks } from '@sim/db/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('LazyCleanup')
|
||||
|
||||
/**
|
||||
* Extract valid field names from a child workflow's start block inputFormat
|
||||
*
|
||||
* @param childWorkflowBlocks - The blocks from the child workflow state
|
||||
* @returns Set of valid field names defined in the child's inputFormat
|
||||
*/
|
||||
function extractValidInputFieldNames(childWorkflowBlocks: Record<string, any>): Set<string> | null {
|
||||
const validFieldNames = new Set<string>()
|
||||
|
||||
const startBlock = Object.values(childWorkflowBlocks).find((block: any) => {
|
||||
const blockType = block?.type
|
||||
return blockType === 'start_trigger' || blockType === 'input_trigger' || blockType === 'starter'
|
||||
})
|
||||
|
||||
if (!startBlock) {
|
||||
logger.debug('No start block found in child workflow')
|
||||
return null
|
||||
}
|
||||
|
||||
const inputFormat =
|
||||
(startBlock as any)?.subBlocks?.inputFormat?.value ??
|
||||
(startBlock as any)?.config?.params?.inputFormat
|
||||
|
||||
if (!Array.isArray(inputFormat)) {
|
||||
logger.debug('No inputFormat array found in child workflow start block')
|
||||
return null
|
||||
}
|
||||
|
||||
// Extract field names
|
||||
for (const field of inputFormat) {
|
||||
if (field?.name && typeof field.name === 'string') {
|
||||
const fieldName = field.name.trim()
|
||||
if (fieldName) {
|
||||
validFieldNames.add(fieldName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return validFieldNames
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up orphaned inputMapping fields that don't exist in child workflow's inputFormat.
|
||||
* This is a lazy cleanup that only runs at execution time and only persists if changes are needed.
|
||||
*
|
||||
* @param parentWorkflowId - The parent workflow ID
|
||||
* @param parentBlockId - The workflow block ID in the parent
|
||||
* @param currentInputMapping - The current inputMapping value from the parent block
|
||||
* @param childWorkflowBlocks - The blocks from the child workflow
|
||||
* @returns The cleaned inputMapping (only different if cleanup was needed)
|
||||
*/
|
||||
export async function lazyCleanupInputMapping(
|
||||
parentWorkflowId: string,
|
||||
parentBlockId: string,
|
||||
currentInputMapping: any,
|
||||
childWorkflowBlocks: Record<string, any>
|
||||
): Promise<any> {
|
||||
try {
|
||||
if (
|
||||
!currentInputMapping ||
|
||||
typeof currentInputMapping !== 'object' ||
|
||||
Array.isArray(currentInputMapping)
|
||||
) {
|
||||
return currentInputMapping
|
||||
}
|
||||
|
||||
const validFieldNames = extractValidInputFieldNames(childWorkflowBlocks)
|
||||
|
||||
if (!validFieldNames || validFieldNames.size === 0) {
|
||||
logger.debug('Child workflow has no inputFormat fields, skipping cleanup')
|
||||
return currentInputMapping
|
||||
}
|
||||
|
||||
const orphanedFields: string[] = []
|
||||
for (const fieldName of Object.keys(currentInputMapping)) {
|
||||
if (!validFieldNames.has(fieldName)) {
|
||||
orphanedFields.push(fieldName)
|
||||
}
|
||||
}
|
||||
|
||||
if (orphanedFields.length === 0) {
|
||||
return currentInputMapping
|
||||
}
|
||||
|
||||
const cleanedMapping: Record<string, any> = {}
|
||||
for (const [fieldName, fieldValue] of Object.entries(currentInputMapping)) {
|
||||
if (validFieldNames.has(fieldName)) {
|
||||
cleanedMapping[fieldName] = fieldValue
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Lazy cleanup: Removing ${orphanedFields.length} orphaned field(s) from inputMapping in workflow ${parentWorkflowId}, block ${parentBlockId}: ${orphanedFields.join(', ')}`
|
||||
)
|
||||
|
||||
persistCleanedMapping(parentWorkflowId, parentBlockId, cleanedMapping).catch((error) => {
|
||||
logger.error('Failed to persist cleaned inputMapping:', error)
|
||||
})
|
||||
|
||||
return cleanedMapping
|
||||
} catch (error) {
|
||||
logger.error('Error in lazy cleanup:', error)
|
||||
return currentInputMapping
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the cleaned inputMapping to the database
|
||||
*
|
||||
* @param workflowId - The workflow ID
|
||||
* @param blockId - The block ID
|
||||
* @param cleanedMapping - The cleaned inputMapping value
|
||||
*/
|
||||
async function persistCleanedMapping(
|
||||
workflowId: string,
|
||||
blockId: string,
|
||||
cleanedMapping: Record<string, any>
|
||||
): Promise<void> {
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
const [block] = await tx
|
||||
.select({ subBlocks: workflowBlocks.subBlocks })
|
||||
.from(workflowBlocks)
|
||||
.where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)))
|
||||
.limit(1)
|
||||
|
||||
if (!block) {
|
||||
logger.warn(`Block ${blockId} not found in workflow ${workflowId}, skipping persistence`)
|
||||
return
|
||||
}
|
||||
|
||||
const subBlocks = (block.subBlocks as Record<string, any>) || {}
|
||||
|
||||
if (subBlocks.inputMapping) {
|
||||
subBlocks.inputMapping = {
|
||||
...subBlocks.inputMapping,
|
||||
value: cleanedMapping,
|
||||
}
|
||||
|
||||
// Persist updated subBlocks
|
||||
await tx
|
||||
.update(workflowBlocks)
|
||||
.set({
|
||||
subBlocks: subBlocks,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)))
|
||||
|
||||
logger.info(`Successfully persisted cleaned inputMapping for block ${blockId}`)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error persisting cleaned mapping:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
88
apps/sim/hooks/queries/oauth-credentials.ts
Normal file
88
apps/sim/hooks/queries/oauth-credentials.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { Credential } from '@/lib/oauth'
|
||||
import { fetchJson } from '@/hooks/selectors/helpers'
|
||||
|
||||
interface CredentialListResponse {
|
||||
credentials?: Credential[]
|
||||
}
|
||||
|
||||
interface CredentialDetailResponse {
|
||||
credentials?: Credential[]
|
||||
}
|
||||
|
||||
export const oauthCredentialKeys = {
|
||||
list: (providerId?: string) => ['oauthCredentials', providerId ?? 'none'] as const,
|
||||
detail: (credentialId?: string, workflowId?: string) =>
|
||||
['oauthCredentialDetail', credentialId ?? 'none', workflowId ?? 'none'] as const,
|
||||
}
|
||||
|
||||
export async function fetchOAuthCredentials(providerId: string): Promise<Credential[]> {
|
||||
if (!providerId) return []
|
||||
const data = await fetchJson<CredentialListResponse>('/api/auth/oauth/credentials', {
|
||||
searchParams: { provider: providerId },
|
||||
})
|
||||
return data.credentials ?? []
|
||||
}
|
||||
|
||||
export async function fetchOAuthCredentialDetail(
|
||||
credentialId: string,
|
||||
workflowId?: string
|
||||
): Promise<Credential[]> {
|
||||
if (!credentialId) return []
|
||||
const data = await fetchJson<CredentialDetailResponse>('/api/auth/oauth/credentials', {
|
||||
searchParams: {
|
||||
credentialId,
|
||||
workflowId,
|
||||
},
|
||||
})
|
||||
return data.credentials ?? []
|
||||
}
|
||||
|
||||
export function useOAuthCredentials(providerId?: string, enabled = true) {
|
||||
return useQuery<Credential[]>({
|
||||
queryKey: oauthCredentialKeys.list(providerId),
|
||||
queryFn: () => fetchOAuthCredentials(providerId ?? ''),
|
||||
enabled: Boolean(providerId) && enabled,
|
||||
staleTime: 60 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useOAuthCredentialDetail(
|
||||
credentialId?: string,
|
||||
workflowId?: string,
|
||||
enabled = true
|
||||
) {
|
||||
return useQuery<Credential[]>({
|
||||
queryKey: oauthCredentialKeys.detail(credentialId, workflowId),
|
||||
queryFn: () => fetchOAuthCredentialDetail(credentialId ?? '', workflowId),
|
||||
enabled: Boolean(credentialId) && enabled,
|
||||
staleTime: 60 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useCredentialName(credentialId?: string, providerId?: string, workflowId?: string) {
|
||||
const { data: credentials = [], isFetching: credentialsLoading } = useOAuthCredentials(
|
||||
providerId,
|
||||
Boolean(providerId)
|
||||
)
|
||||
|
||||
const selectedCredential = credentials.find((cred) => cred.id === credentialId)
|
||||
|
||||
const shouldFetchDetail = Boolean(credentialId && !selectedCredential && providerId && workflowId)
|
||||
|
||||
const { data: foreignCredentials = [], isFetching: foreignLoading } = useOAuthCredentialDetail(
|
||||
shouldFetchDetail ? credentialId : undefined,
|
||||
workflowId,
|
||||
shouldFetchDetail
|
||||
)
|
||||
|
||||
const hasForeignMeta = foreignCredentials.length > 0
|
||||
|
||||
const displayName = selectedCredential?.name ?? (hasForeignMeta ? 'Saved by collaborator' : null)
|
||||
|
||||
return {
|
||||
displayName,
|
||||
isLoading: credentialsLoading || foreignLoading,
|
||||
hasForeignMeta,
|
||||
}
|
||||
}
|
||||
396
apps/sim/hooks/queries/templates.ts
Normal file
396
apps/sim/hooks/queries/templates.ts
Normal file
@@ -0,0 +1,396 @@
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('TemplateQueries')
|
||||
|
||||
export const templateKeys = {
|
||||
all: ['templates'] as const,
|
||||
lists: () => [...templateKeys.all, 'list'] as const,
|
||||
list: (filters?: TemplateListFilters) => [...templateKeys.lists(), filters ?? {}] as const,
|
||||
details: () => [...templateKeys.all, 'detail'] as const,
|
||||
detail: (templateId?: string) => [...templateKeys.details(), templateId ?? ''] as const,
|
||||
byWorkflow: (workflowId?: string) =>
|
||||
[...templateKeys.all, 'byWorkflow', workflowId ?? ''] as const,
|
||||
}
|
||||
|
||||
export interface TemplateListFilters {
|
||||
search?: string
|
||||
status?: 'pending' | 'approved' | 'rejected'
|
||||
workflowId?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
includeAllStatuses?: boolean
|
||||
}
|
||||
|
||||
export interface TemplateCreator {
|
||||
id: string
|
||||
name: string
|
||||
referenceType: 'user' | 'organization'
|
||||
referenceId: string
|
||||
email?: string
|
||||
website?: string
|
||||
profileImageUrl?: string | null
|
||||
details?: {
|
||||
about?: string
|
||||
xUrl?: string
|
||||
linkedinUrl?: string
|
||||
websiteUrl?: string
|
||||
contactEmail?: string
|
||||
} | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface Template {
|
||||
id: string
|
||||
workflowId: string
|
||||
name: string
|
||||
details?: {
|
||||
tagline?: string
|
||||
about?: string
|
||||
}
|
||||
creatorId?: string
|
||||
creator?: TemplateCreator
|
||||
views: number
|
||||
stars: number
|
||||
status: 'pending' | 'approved' | 'rejected'
|
||||
tags: string[]
|
||||
requiredCredentials: Record<string, any>
|
||||
state: any
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
isStarred?: boolean
|
||||
isSuperUser?: boolean
|
||||
}
|
||||
|
||||
export interface TemplatesResponse {
|
||||
data: Template[]
|
||||
pagination: {
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
page: number
|
||||
totalPages: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface TemplateDetailResponse {
|
||||
data: Template
|
||||
}
|
||||
|
||||
export interface CreateTemplateInput {
|
||||
workflowId: string
|
||||
name: string
|
||||
details?: {
|
||||
tagline?: string
|
||||
about?: string
|
||||
}
|
||||
creatorId?: string
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
export interface UpdateTemplateInput {
|
||||
name?: string
|
||||
details?: {
|
||||
tagline?: string
|
||||
about?: string
|
||||
}
|
||||
creatorId?: string
|
||||
tags?: string[]
|
||||
updateState?: boolean
|
||||
}
|
||||
|
||||
async function fetchTemplates(filters?: TemplateListFilters): Promise<TemplatesResponse> {
|
||||
const params = new URLSearchParams()
|
||||
|
||||
if (filters?.search) params.set('search', filters.search)
|
||||
if (filters?.status) params.set('status', filters.status)
|
||||
if (filters?.workflowId) params.set('workflowId', filters.workflowId)
|
||||
if (filters?.includeAllStatuses) params.set('includeAllStatuses', 'true')
|
||||
params.set('limit', (filters?.limit ?? 50).toString())
|
||||
params.set('offset', (filters?.offset ?? 0).toString())
|
||||
|
||||
const response = await fetch(`/api/templates?${params.toString()}`)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.error || 'Failed to fetch templates')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
async function fetchTemplate(templateId: string): Promise<TemplateDetailResponse> {
|
||||
const response = await fetch(`/api/templates/${templateId}`)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.error || 'Failed to fetch template')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
async function fetchTemplateByWorkflow(workflowId: string): Promise<Template | null> {
|
||||
const response = await fetch(`/api/templates?workflowId=${workflowId}&limit=1`)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.error || 'Failed to fetch template')
|
||||
}
|
||||
|
||||
const result: TemplatesResponse = await response.json()
|
||||
return result.data?.[0] || null
|
||||
}
|
||||
|
||||
export function useTemplates(
|
||||
filters?: TemplateListFilters,
|
||||
options?: {
|
||||
enabled?: boolean
|
||||
}
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: templateKeys.list(filters),
|
||||
queryFn: () => fetchTemplates(filters),
|
||||
enabled: options?.enabled ?? true,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes - templates don't change frequently
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
export function useTemplate(
|
||||
templateId?: string,
|
||||
options?: {
|
||||
enabled?: boolean
|
||||
}
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: templateKeys.detail(templateId),
|
||||
queryFn: () => fetchTemplate(templateId as string),
|
||||
enabled: (options?.enabled ?? true) && Boolean(templateId),
|
||||
staleTime: 10 * 60 * 1000, // 10 minutes - individual templates are fairly static
|
||||
select: (data) => data.data,
|
||||
})
|
||||
}
|
||||
|
||||
export function useTemplateByWorkflow(
|
||||
workflowId?: string,
|
||||
options?: {
|
||||
enabled?: boolean
|
||||
}
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: templateKeys.byWorkflow(workflowId),
|
||||
queryFn: () => fetchTemplateByWorkflow(workflowId as string),
|
||||
enabled: (options?.enabled ?? true) && Boolean(workflowId),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateTemplate() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data: CreateTemplateInput) => {
|
||||
const response = await fetch('/api/templates', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.error || 'Failed to create template')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: templateKeys.lists() })
|
||||
queryClient.invalidateQueries({ queryKey: templateKeys.byWorkflow(variables.workflowId) })
|
||||
logger.info('Template created successfully')
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error('Failed to create template', error)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateTemplate() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, data }: { id: string; data: UpdateTemplateInput }) => {
|
||||
const response = await fetch(`/api/templates/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.error || 'Failed to update template')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
},
|
||||
onMutate: async ({ id, data }) => {
|
||||
await queryClient.cancelQueries({ queryKey: templateKeys.detail(id) })
|
||||
|
||||
const previousTemplate = queryClient.getQueryData<TemplateDetailResponse>(
|
||||
templateKeys.detail(id)
|
||||
)
|
||||
|
||||
if (previousTemplate) {
|
||||
queryClient.setQueryData<TemplateDetailResponse>(templateKeys.detail(id), {
|
||||
...previousTemplate,
|
||||
data: {
|
||||
...previousTemplate.data,
|
||||
...data,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return { previousTemplate }
|
||||
},
|
||||
onError: (error, { id }, context) => {
|
||||
if (context?.previousTemplate) {
|
||||
queryClient.setQueryData(templateKeys.detail(id), context.previousTemplate)
|
||||
}
|
||||
logger.error('Failed to update template', error)
|
||||
},
|
||||
onSuccess: (result, { id }) => {
|
||||
queryClient.setQueryData<TemplateDetailResponse>(templateKeys.detail(id), result)
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: templateKeys.lists() })
|
||||
|
||||
if (result.data?.workflowId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: templateKeys.byWorkflow(result.data.workflowId),
|
||||
})
|
||||
}
|
||||
|
||||
logger.info('Template updated successfully')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteTemplate() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (templateId: string) => {
|
||||
const response = await fetch(`/api/templates/${templateId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.error || 'Failed to delete template')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
},
|
||||
onSuccess: (_, templateId) => {
|
||||
queryClient.removeQueries({ queryKey: templateKeys.detail(templateId) })
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: templateKeys.lists() })
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...templateKeys.all, 'byWorkflow'],
|
||||
exact: false,
|
||||
})
|
||||
|
||||
logger.info('Template deleted successfully')
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error('Failed to delete template', error)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useStarTemplate() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
templateId,
|
||||
action,
|
||||
}: {
|
||||
templateId: string
|
||||
action: 'add' | 'remove'
|
||||
}) => {
|
||||
const method = action === 'add' ? 'POST' : 'DELETE'
|
||||
const response = await fetch(`/api/templates/${templateId}/star`, { method })
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.error || 'Failed to toggle star')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
},
|
||||
onMutate: async ({ templateId, action }) => {
|
||||
await queryClient.cancelQueries({ queryKey: templateKeys.detail(templateId) })
|
||||
|
||||
const previousTemplate = queryClient.getQueryData<TemplateDetailResponse>(
|
||||
templateKeys.detail(templateId)
|
||||
)
|
||||
|
||||
if (previousTemplate) {
|
||||
const newStarCount =
|
||||
action === 'add'
|
||||
? previousTemplate.data.stars + 1
|
||||
: Math.max(0, previousTemplate.data.stars - 1)
|
||||
|
||||
queryClient.setQueryData<TemplateDetailResponse>(templateKeys.detail(templateId), {
|
||||
...previousTemplate,
|
||||
data: {
|
||||
...previousTemplate.data,
|
||||
stars: newStarCount,
|
||||
isStarred: action === 'add',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const listQueries = queryClient.getQueriesData<TemplatesResponse>({
|
||||
queryKey: templateKeys.lists(),
|
||||
})
|
||||
|
||||
listQueries.forEach(([key, data]) => {
|
||||
if (!data) return
|
||||
queryClient.setQueryData<TemplatesResponse>(key, {
|
||||
...data,
|
||||
data: data.data.map((template) => {
|
||||
if (template.id === templateId) {
|
||||
const newStarCount =
|
||||
action === 'add' ? template.stars + 1 : Math.max(0, template.stars - 1)
|
||||
return {
|
||||
...template,
|
||||
stars: newStarCount,
|
||||
isStarred: action === 'add',
|
||||
}
|
||||
}
|
||||
return template
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
return { previousTemplate }
|
||||
},
|
||||
onError: (error, { templateId }, context) => {
|
||||
if (context?.previousTemplate) {
|
||||
queryClient.setQueryData(templateKeys.detail(templateId), context.previousTemplate)
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: templateKeys.lists() })
|
||||
|
||||
logger.error('Failed to toggle star', error)
|
||||
},
|
||||
onSettled: (_, __, { templateId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: templateKeys.detail(templateId) })
|
||||
queryClient.invalidateQueries({ queryKey: templateKeys.lists() })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -105,23 +105,18 @@ export function useCreateWorkflow() {
|
||||
|
||||
const { workflowState } = buildDefaultWorkflowArtifacts()
|
||||
|
||||
fetch(`/api/workflows/${workflowId}/state`, {
|
||||
const stateResponse = await fetch(`/api/workflows/${workflowId}/state`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(workflowState),
|
||||
})
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
response.text().then((text) => {
|
||||
logger.error('Failed to persist default Start block:', text)
|
||||
})
|
||||
} else {
|
||||
logger.info('Successfully persisted default Start block')
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('Error persisting default Start block:', error)
|
||||
})
|
||||
|
||||
if (!stateResponse.ok) {
|
||||
const text = await stateResponse.text()
|
||||
logger.error('Failed to persist default Start block:', text)
|
||||
} else {
|
||||
logger.info('Successfully persisted default Start block')
|
||||
}
|
||||
|
||||
return {
|
||||
id: workflowId,
|
||||
|
||||
61
apps/sim/hooks/selectors/helpers.ts
Normal file
61
apps/sim/hooks/selectors/helpers.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('SelectorHelpers')
|
||||
|
||||
interface FetchJsonOptions extends RequestInit {
|
||||
searchParams?: Record<string, string | number | undefined | null>
|
||||
}
|
||||
|
||||
export async function fetchJson<T>(url: string, options: FetchJsonOptions = {}): Promise<T> {
|
||||
const { searchParams, headers, ...rest } = options
|
||||
let finalUrl = url
|
||||
if (searchParams) {
|
||||
const params = new URLSearchParams()
|
||||
Object.entries(searchParams).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null || value === '') return
|
||||
params.set(key, String(value))
|
||||
})
|
||||
const qs = params.toString()
|
||||
if (qs) {
|
||||
finalUrl = `${url}${url.includes('?') ? '&' : '?'}${qs}`
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(finalUrl, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...headers,
|
||||
},
|
||||
...rest,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
let message = `Failed request ${response.status}`
|
||||
try {
|
||||
const err = await response.json()
|
||||
message = err.error || err.message || message
|
||||
} catch (error) {
|
||||
logger.warn('Failed to parse error response', { error })
|
||||
}
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
interface TokenResponse {
|
||||
accessToken?: string
|
||||
}
|
||||
|
||||
export async function fetchOAuthToken(
|
||||
credentialId: string,
|
||||
workflowId?: string
|
||||
): Promise<string | null> {
|
||||
if (!credentialId) return null
|
||||
const body = JSON.stringify({ credentialId, workflowId })
|
||||
const token = await fetchJson<TokenResponse>('/api/auth/oauth/token', {
|
||||
method: 'POST',
|
||||
body,
|
||||
})
|
||||
return token.accessToken ?? null
|
||||
}
|
||||
646
apps/sim/hooks/selectors/registry.ts
Normal file
646
apps/sim/hooks/selectors/registry.ts
Normal file
@@ -0,0 +1,646 @@
|
||||
import { fetchJson, fetchOAuthToken } from './helpers'
|
||||
import type {
|
||||
SelectorContext,
|
||||
SelectorDefinition,
|
||||
SelectorKey,
|
||||
SelectorOption,
|
||||
SelectorQueryArgs,
|
||||
} from './types'
|
||||
|
||||
const SELECTOR_STALE = 60 * 1000
|
||||
|
||||
type SlackChannel = { id: string; name: string }
|
||||
type FolderResponse = { id: string; name: string }
|
||||
type PlannerTask = { id: string; title: string }
|
||||
|
||||
const ensureCredential = (context: SelectorContext, key: SelectorKey): string => {
|
||||
if (!context.credentialId) {
|
||||
throw new Error(`Missing credential for selector ${key}`)
|
||||
}
|
||||
return context.credentialId
|
||||
}
|
||||
|
||||
const ensureDomain = (context: SelectorContext, key: SelectorKey): string => {
|
||||
if (!context.domain) {
|
||||
throw new Error(`Missing domain for selector ${key}`)
|
||||
}
|
||||
return context.domain
|
||||
}
|
||||
|
||||
const ensureKnowledgeBase = (context: SelectorContext): string => {
|
||||
if (!context.knowledgeBaseId) {
|
||||
throw new Error('Missing knowledge base id')
|
||||
}
|
||||
return context.knowledgeBaseId
|
||||
}
|
||||
|
||||
const registry: Record<SelectorKey, SelectorDefinition> = {
|
||||
'slack.channels': {
|
||||
key: 'slack.channels',
|
||||
staleTime: SELECTOR_STALE,
|
||||
getQueryKey: ({ context }: SelectorQueryArgs) => [
|
||||
'selectors',
|
||||
'slack.channels',
|
||||
context.credentialId ?? 'none',
|
||||
],
|
||||
enabled: ({ context }) => Boolean(context.credentialId),
|
||||
fetchList: async ({ context }: SelectorQueryArgs) => {
|
||||
const body = JSON.stringify({
|
||||
credential: context.credentialId,
|
||||
workflowId: context.workflowId,
|
||||
})
|
||||
const data = await fetchJson<{ channels: SlackChannel[] }>('/api/tools/slack/channels', {
|
||||
method: 'POST',
|
||||
body,
|
||||
})
|
||||
return (data.channels || []).map((channel) => ({
|
||||
id: channel.id,
|
||||
label: `#${channel.name}`,
|
||||
}))
|
||||
},
|
||||
},
|
||||
'gmail.labels': {
|
||||
key: 'gmail.labels',
|
||||
staleTime: SELECTOR_STALE,
|
||||
getQueryKey: ({ context }: SelectorQueryArgs) => [
|
||||
'selectors',
|
||||
'gmail.labels',
|
||||
context.credentialId ?? 'none',
|
||||
],
|
||||
enabled: ({ context }) => Boolean(context.credentialId),
|
||||
fetchList: async ({ context }: SelectorQueryArgs) => {
|
||||
const data = await fetchJson<{ labels: FolderResponse[] }>('/api/tools/gmail/labels', {
|
||||
searchParams: { credentialId: context.credentialId },
|
||||
})
|
||||
return (data.labels || []).map((label) => ({
|
||||
id: label.id,
|
||||
label: label.name,
|
||||
}))
|
||||
},
|
||||
},
|
||||
'outlook.folders': {
|
||||
key: 'outlook.folders',
|
||||
staleTime: SELECTOR_STALE,
|
||||
getQueryKey: ({ context }: SelectorQueryArgs) => [
|
||||
'selectors',
|
||||
'outlook.folders',
|
||||
context.credentialId ?? 'none',
|
||||
],
|
||||
enabled: ({ context }) => Boolean(context.credentialId),
|
||||
fetchList: async ({ context }: SelectorQueryArgs) => {
|
||||
const data = await fetchJson<{ folders: FolderResponse[] }>('/api/tools/outlook/folders', {
|
||||
searchParams: { credentialId: context.credentialId },
|
||||
})
|
||||
return (data.folders || []).map((folder) => ({
|
||||
id: folder.id,
|
||||
label: folder.name,
|
||||
}))
|
||||
},
|
||||
},
|
||||
'google.calendar': {
|
||||
key: 'google.calendar',
|
||||
staleTime: SELECTOR_STALE,
|
||||
getQueryKey: ({ context }: SelectorQueryArgs) => [
|
||||
'selectors',
|
||||
'google.calendar',
|
||||
context.credentialId ?? 'none',
|
||||
],
|
||||
enabled: ({ context }) => Boolean(context.credentialId),
|
||||
fetchList: async ({ context }: SelectorQueryArgs) => {
|
||||
const data = await fetchJson<{ calendars: { id: string; summary: string }[] }>(
|
||||
'/api/tools/google_calendar/calendars',
|
||||
{ searchParams: { credentialId: context.credentialId } }
|
||||
)
|
||||
return (data.calendars || []).map((calendar) => ({
|
||||
id: calendar.id,
|
||||
label: calendar.summary,
|
||||
}))
|
||||
},
|
||||
},
|
||||
'microsoft.teams': {
|
||||
key: 'microsoft.teams',
|
||||
staleTime: SELECTOR_STALE,
|
||||
getQueryKey: ({ context }: SelectorQueryArgs) => [
|
||||
'selectors',
|
||||
'microsoft.teams',
|
||||
context.credentialId ?? 'none',
|
||||
],
|
||||
enabled: ({ context }) => Boolean(context.credentialId),
|
||||
fetchList: async ({ context }: SelectorQueryArgs) => {
|
||||
const body = JSON.stringify({ credential: context.credentialId })
|
||||
const data = await fetchJson<{ teams: { id: string; displayName: string }[] }>(
|
||||
'/api/tools/microsoft-teams/teams',
|
||||
{ method: 'POST', body }
|
||||
)
|
||||
return (data.teams || []).map((team) => ({
|
||||
id: team.id,
|
||||
label: team.displayName,
|
||||
}))
|
||||
},
|
||||
},
|
||||
'wealthbox.contacts': {
|
||||
key: 'wealthbox.contacts',
|
||||
staleTime: SELECTOR_STALE,
|
||||
getQueryKey: ({ context }: SelectorQueryArgs) => [
|
||||
'selectors',
|
||||
'wealthbox.contacts',
|
||||
context.credentialId ?? 'none',
|
||||
],
|
||||
enabled: ({ context }) => Boolean(context.credentialId),
|
||||
fetchList: async ({ context }: SelectorQueryArgs) => {
|
||||
const data = await fetchJson<{ items: { id: string; name: string }[] }>(
|
||||
'/api/tools/wealthbox/items',
|
||||
{
|
||||
searchParams: { credentialId: context.credentialId, type: 'contact' },
|
||||
}
|
||||
)
|
||||
return (data.items || []).map((item) => ({
|
||||
id: item.id,
|
||||
label: item.name,
|
||||
}))
|
||||
},
|
||||
},
|
||||
'sharepoint.sites': {
|
||||
key: 'sharepoint.sites',
|
||||
staleTime: SELECTOR_STALE,
|
||||
getQueryKey: ({ context }: SelectorQueryArgs) => [
|
||||
'selectors',
|
||||
'sharepoint.sites',
|
||||
context.credentialId ?? 'none',
|
||||
],
|
||||
enabled: ({ context }) => Boolean(context.credentialId),
|
||||
fetchList: async ({ context }: SelectorQueryArgs) => {
|
||||
const data = await fetchJson<{ files: { id: string; name: string }[] }>(
|
||||
'/api/tools/sharepoint/sites',
|
||||
{
|
||||
searchParams: { credentialId: context.credentialId },
|
||||
}
|
||||
)
|
||||
return (data.files || []).map((file) => ({
|
||||
id: file.id,
|
||||
label: file.name,
|
||||
}))
|
||||
},
|
||||
},
|
||||
'microsoft.planner': {
|
||||
key: 'microsoft.planner',
|
||||
staleTime: SELECTOR_STALE,
|
||||
getQueryKey: ({ context }: SelectorQueryArgs) => [
|
||||
'selectors',
|
||||
'microsoft.planner',
|
||||
context.credentialId ?? 'none',
|
||||
context.planId ?? 'none',
|
||||
],
|
||||
enabled: ({ context }) => Boolean(context.credentialId && context.planId),
|
||||
fetchList: async ({ context }: SelectorQueryArgs) => {
|
||||
const data = await fetchJson<{ tasks: PlannerTask[] }>('/api/tools/microsoft_planner/tasks', {
|
||||
searchParams: {
|
||||
credentialId: context.credentialId,
|
||||
planId: context.planId,
|
||||
},
|
||||
})
|
||||
return (data.tasks || []).map((task) => ({
|
||||
id: task.id,
|
||||
label: task.title,
|
||||
}))
|
||||
},
|
||||
},
|
||||
'jira.projects': {
|
||||
key: 'jira.projects',
|
||||
staleTime: SELECTOR_STALE,
|
||||
getQueryKey: ({ context, search }: SelectorQueryArgs) => [
|
||||
'selectors',
|
||||
'jira.projects',
|
||||
context.credentialId ?? 'none',
|
||||
context.domain ?? 'none',
|
||||
search ?? '',
|
||||
],
|
||||
enabled: ({ context }) => Boolean(context.credentialId && context.domain),
|
||||
fetchList: async ({ context, search }: SelectorQueryArgs) => {
|
||||
const credentialId = ensureCredential(context, 'jira.projects')
|
||||
const domain = ensureDomain(context, 'jira.projects')
|
||||
const accessToken = await fetchOAuthToken(credentialId, context.workflowId)
|
||||
if (!accessToken) {
|
||||
throw new Error('Missing Jira access token')
|
||||
}
|
||||
const data = await fetchJson<{ projects: { id: string; name: string }[] }>(
|
||||
'/api/tools/jira/projects',
|
||||
{
|
||||
searchParams: {
|
||||
domain,
|
||||
accessToken,
|
||||
query: search ?? '',
|
||||
},
|
||||
}
|
||||
)
|
||||
return (data.projects || []).map((project) => ({
|
||||
id: project.id,
|
||||
label: project.name,
|
||||
}))
|
||||
},
|
||||
fetchById: async ({ context, detailId }: SelectorQueryArgs) => {
|
||||
if (!detailId) return null
|
||||
const credentialId = ensureCredential(context, 'jira.projects')
|
||||
const domain = ensureDomain(context, 'jira.projects')
|
||||
const accessToken = await fetchOAuthToken(credentialId, context.workflowId)
|
||||
if (!accessToken) {
|
||||
throw new Error('Missing Jira access token')
|
||||
}
|
||||
const data = await fetchJson<{ project?: { id: string; name: string } }>(
|
||||
'/api/tools/jira/projects',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
domain,
|
||||
accessToken,
|
||||
projectId: detailId,
|
||||
}),
|
||||
}
|
||||
)
|
||||
if (!data.project) return null
|
||||
return {
|
||||
id: data.project.id,
|
||||
label: data.project.name,
|
||||
}
|
||||
},
|
||||
},
|
||||
'jira.issues': {
|
||||
key: 'jira.issues',
|
||||
staleTime: 15 * 1000,
|
||||
getQueryKey: ({ context, search }: SelectorQueryArgs) => [
|
||||
'selectors',
|
||||
'jira.issues',
|
||||
context.credentialId ?? 'none',
|
||||
context.domain ?? 'none',
|
||||
context.projectId ?? 'none',
|
||||
search ?? '',
|
||||
],
|
||||
enabled: ({ context }) => Boolean(context.credentialId && context.domain),
|
||||
fetchList: async ({ context, search }: SelectorQueryArgs) => {
|
||||
const credentialId = ensureCredential(context, 'jira.issues')
|
||||
const domain = ensureDomain(context, 'jira.issues')
|
||||
const accessToken = await fetchOAuthToken(credentialId, context.workflowId)
|
||||
if (!accessToken) {
|
||||
throw new Error('Missing Jira access token')
|
||||
}
|
||||
const data = await fetchJson<{
|
||||
sections?: { issues: { id?: string; key?: string; summary?: string }[] }[]
|
||||
}>('/api/tools/jira/issues', {
|
||||
searchParams: {
|
||||
domain,
|
||||
accessToken,
|
||||
projectId: context.projectId,
|
||||
query: search ?? '',
|
||||
},
|
||||
})
|
||||
const issues =
|
||||
data.sections?.flatMap((section) =>
|
||||
(section.issues || []).map((issue) => ({
|
||||
id: issue.id || issue.key || '',
|
||||
name: issue.summary || issue.key || '',
|
||||
}))
|
||||
) || []
|
||||
return issues
|
||||
.filter((issue) => issue.id)
|
||||
.map((issue) => ({ id: issue.id, label: issue.name || issue.id }))
|
||||
},
|
||||
fetchById: async ({ context, detailId }: SelectorQueryArgs) => {
|
||||
if (!detailId) return null
|
||||
const credentialId = ensureCredential(context, 'jira.issues')
|
||||
const domain = ensureDomain(context, 'jira.issues')
|
||||
const accessToken = await fetchOAuthToken(credentialId, context.workflowId)
|
||||
if (!accessToken) {
|
||||
throw new Error('Missing Jira access token')
|
||||
}
|
||||
const data = await fetchJson<{ issues?: { id: string; name: string }[] }>(
|
||||
'/api/tools/jira/issues',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
domain,
|
||||
accessToken,
|
||||
issueKeys: [detailId],
|
||||
}),
|
||||
}
|
||||
)
|
||||
const issue = data.issues?.[0]
|
||||
if (!issue) return null
|
||||
return { id: issue.id, label: issue.name }
|
||||
},
|
||||
},
|
||||
'linear.teams': {
|
||||
key: 'linear.teams',
|
||||
staleTime: SELECTOR_STALE,
|
||||
getQueryKey: ({ context }: SelectorQueryArgs) => [
|
||||
'selectors',
|
||||
'linear.teams',
|
||||
context.credentialId ?? 'none',
|
||||
],
|
||||
enabled: ({ context }) => Boolean(context.credentialId),
|
||||
fetchList: async ({ context }: SelectorQueryArgs) => {
|
||||
const credentialId = ensureCredential(context, 'linear.teams')
|
||||
const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId })
|
||||
const data = await fetchJson<{ teams: { id: string; name: string }[] }>(
|
||||
'/api/tools/linear/teams',
|
||||
{
|
||||
method: 'POST',
|
||||
body,
|
||||
}
|
||||
)
|
||||
return (data.teams || []).map((team) => ({
|
||||
id: team.id,
|
||||
label: team.name,
|
||||
}))
|
||||
},
|
||||
},
|
||||
'linear.projects': {
|
||||
key: 'linear.projects',
|
||||
staleTime: SELECTOR_STALE,
|
||||
getQueryKey: ({ context }: SelectorQueryArgs) => [
|
||||
'selectors',
|
||||
'linear.projects',
|
||||
context.credentialId ?? 'none',
|
||||
context.teamId ?? 'none',
|
||||
],
|
||||
enabled: ({ context }) => Boolean(context.credentialId && context.teamId),
|
||||
fetchList: async ({ context }: SelectorQueryArgs) => {
|
||||
const credentialId = ensureCredential(context, 'linear.projects')
|
||||
const body = JSON.stringify({
|
||||
credential: credentialId,
|
||||
teamId: context.teamId,
|
||||
workflowId: context.workflowId,
|
||||
})
|
||||
const data = await fetchJson<{ projects: { id: string; name: string }[] }>(
|
||||
'/api/tools/linear/projects',
|
||||
{
|
||||
method: 'POST',
|
||||
body,
|
||||
}
|
||||
)
|
||||
return (data.projects || []).map((project) => ({
|
||||
id: project.id,
|
||||
label: project.name,
|
||||
}))
|
||||
},
|
||||
},
|
||||
'confluence.pages': {
|
||||
key: 'confluence.pages',
|
||||
staleTime: SELECTOR_STALE,
|
||||
getQueryKey: ({ context, search }: SelectorQueryArgs) => [
|
||||
'selectors',
|
||||
'confluence.pages',
|
||||
context.credentialId ?? 'none',
|
||||
context.domain ?? 'none',
|
||||
search ?? '',
|
||||
],
|
||||
enabled: ({ context }) => Boolean(context.credentialId && context.domain),
|
||||
fetchList: async ({ context, search }: SelectorQueryArgs) => {
|
||||
const credentialId = ensureCredential(context, 'confluence.pages')
|
||||
const domain = ensureDomain(context, 'confluence.pages')
|
||||
const accessToken = await fetchOAuthToken(credentialId, context.workflowId)
|
||||
if (!accessToken) {
|
||||
throw new Error('Missing Confluence access token')
|
||||
}
|
||||
const data = await fetchJson<{ files: { id: string; name: string }[] }>(
|
||||
'/api/tools/confluence/pages',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
domain,
|
||||
accessToken,
|
||||
title: search,
|
||||
}),
|
||||
}
|
||||
)
|
||||
return (data.files || []).map((file) => ({
|
||||
id: file.id,
|
||||
label: file.name,
|
||||
}))
|
||||
},
|
||||
fetchById: async ({ context, detailId }: SelectorQueryArgs) => {
|
||||
if (!detailId) return null
|
||||
const credentialId = ensureCredential(context, 'confluence.pages')
|
||||
const domain = ensureDomain(context, 'confluence.pages')
|
||||
const accessToken = await fetchOAuthToken(credentialId, context.workflowId)
|
||||
if (!accessToken) {
|
||||
throw new Error('Missing Confluence access token')
|
||||
}
|
||||
const data = await fetchJson<{ id: string; title: string }>('/api/tools/confluence/page', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
domain,
|
||||
accessToken,
|
||||
pageId: detailId,
|
||||
}),
|
||||
})
|
||||
return { id: data.id, label: data.title }
|
||||
},
|
||||
},
|
||||
'onedrive.files': {
|
||||
key: 'onedrive.files',
|
||||
staleTime: SELECTOR_STALE,
|
||||
getQueryKey: ({ context }: SelectorQueryArgs) => [
|
||||
'selectors',
|
||||
'onedrive.files',
|
||||
context.credentialId ?? 'none',
|
||||
],
|
||||
enabled: ({ context }) => Boolean(context.credentialId),
|
||||
fetchList: async ({ context }: SelectorQueryArgs) => {
|
||||
const credentialId = ensureCredential(context, 'onedrive.files')
|
||||
const data = await fetchJson<{ files: { id: string; name: string }[] }>(
|
||||
'/api/tools/onedrive/files',
|
||||
{
|
||||
searchParams: { credentialId },
|
||||
}
|
||||
)
|
||||
return (data.files || []).map((file) => ({
|
||||
id: file.id,
|
||||
label: file.name,
|
||||
}))
|
||||
},
|
||||
},
|
||||
'onedrive.folders': {
|
||||
key: 'onedrive.folders',
|
||||
staleTime: SELECTOR_STALE,
|
||||
getQueryKey: ({ context }: SelectorQueryArgs) => [
|
||||
'selectors',
|
||||
'onedrive.folders',
|
||||
context.credentialId ?? 'none',
|
||||
],
|
||||
enabled: ({ context }) => Boolean(context.credentialId),
|
||||
fetchList: async ({ context }: SelectorQueryArgs) => {
|
||||
const credentialId = ensureCredential(context, 'onedrive.folders')
|
||||
const data = await fetchJson<{ files: { id: string; name: string }[] }>(
|
||||
'/api/tools/onedrive/folders',
|
||||
{
|
||||
searchParams: { credentialId },
|
||||
}
|
||||
)
|
||||
return (data.files || []).map((file) => ({
|
||||
id: file.id,
|
||||
label: file.name,
|
||||
}))
|
||||
},
|
||||
},
|
||||
'google.drive': {
|
||||
key: 'google.drive',
|
||||
staleTime: 15 * 1000,
|
||||
getQueryKey: ({ context, search }: SelectorQueryArgs) => [
|
||||
'selectors',
|
||||
'google.drive',
|
||||
context.credentialId ?? 'none',
|
||||
context.mimeType ?? 'any',
|
||||
context.fileId ?? 'root',
|
||||
search ?? '',
|
||||
],
|
||||
enabled: ({ context }) => Boolean(context.credentialId),
|
||||
fetchList: async ({ context, search }: SelectorQueryArgs) => {
|
||||
const credentialId = ensureCredential(context, 'google.drive')
|
||||
const data = await fetchJson<{ files: { id: string; name: string }[] }>(
|
||||
'/api/tools/drive/files',
|
||||
{
|
||||
searchParams: {
|
||||
credentialId,
|
||||
mimeType: context.mimeType,
|
||||
parentId: context.fileId,
|
||||
query: search,
|
||||
workflowId: context.workflowId,
|
||||
},
|
||||
}
|
||||
)
|
||||
return (data.files || []).map((file) => ({
|
||||
id: file.id,
|
||||
label: file.name,
|
||||
}))
|
||||
},
|
||||
fetchById: async ({ context, detailId }: SelectorQueryArgs) => {
|
||||
if (!detailId) return null
|
||||
const credentialId = ensureCredential(context, 'google.drive')
|
||||
const data = await fetchJson<{ file?: { id: string; name: string } }>(
|
||||
'/api/tools/drive/file',
|
||||
{
|
||||
searchParams: {
|
||||
credentialId,
|
||||
fileId: detailId,
|
||||
workflowId: context.workflowId,
|
||||
},
|
||||
}
|
||||
)
|
||||
const file = data.file
|
||||
if (!file) return null
|
||||
return { id: file.id, label: file.name }
|
||||
},
|
||||
},
|
||||
'microsoft.excel': {
|
||||
key: 'microsoft.excel',
|
||||
staleTime: SELECTOR_STALE,
|
||||
getQueryKey: ({ context, search }: SelectorQueryArgs) => [
|
||||
'selectors',
|
||||
'microsoft.excel',
|
||||
context.credentialId ?? 'none',
|
||||
search ?? '',
|
||||
],
|
||||
enabled: ({ context }) => Boolean(context.credentialId),
|
||||
fetchList: async ({ context, search }: SelectorQueryArgs) => {
|
||||
const credentialId = ensureCredential(context, 'microsoft.excel')
|
||||
const data = await fetchJson<{ files: { id: string; name: string }[] }>(
|
||||
'/api/auth/oauth/microsoft/files',
|
||||
{
|
||||
searchParams: {
|
||||
credentialId,
|
||||
query: search,
|
||||
workflowId: context.workflowId,
|
||||
},
|
||||
}
|
||||
)
|
||||
return (data.files || []).map((file) => ({
|
||||
id: file.id,
|
||||
label: file.name,
|
||||
}))
|
||||
},
|
||||
},
|
||||
'microsoft.word': {
|
||||
key: 'microsoft.word',
|
||||
staleTime: SELECTOR_STALE,
|
||||
getQueryKey: ({ context, search }: SelectorQueryArgs) => [
|
||||
'selectors',
|
||||
'microsoft.word',
|
||||
context.credentialId ?? 'none',
|
||||
search ?? '',
|
||||
],
|
||||
enabled: ({ context }) => Boolean(context.credentialId),
|
||||
fetchList: async ({ context, search }: SelectorQueryArgs) => {
|
||||
const credentialId = ensureCredential(context, 'microsoft.word')
|
||||
const data = await fetchJson<{ files: { id: string; name: string }[] }>(
|
||||
'/api/auth/oauth/microsoft/files',
|
||||
{
|
||||
searchParams: {
|
||||
credentialId,
|
||||
query: search,
|
||||
workflowId: context.workflowId,
|
||||
},
|
||||
}
|
||||
)
|
||||
return (data.files || []).map((file) => ({
|
||||
id: file.id,
|
||||
label: file.name,
|
||||
}))
|
||||
},
|
||||
},
|
||||
'knowledge.documents': {
|
||||
key: 'knowledge.documents',
|
||||
staleTime: SELECTOR_STALE,
|
||||
getQueryKey: ({ context, search }: SelectorQueryArgs) => [
|
||||
'selectors',
|
||||
'knowledge.documents',
|
||||
context.knowledgeBaseId ?? 'none',
|
||||
search ?? '',
|
||||
],
|
||||
enabled: ({ context }) => Boolean(context.knowledgeBaseId),
|
||||
fetchList: async ({ context, search }: SelectorQueryArgs) => {
|
||||
const knowledgeBaseId = ensureKnowledgeBase(context)
|
||||
const data = await fetchJson<{
|
||||
data?: { documents: { id: string; filename: string }[] }
|
||||
}>(`/api/knowledge/${knowledgeBaseId}/documents`, {
|
||||
searchParams: {
|
||||
limit: 200,
|
||||
search,
|
||||
},
|
||||
})
|
||||
const documents = data.data?.documents || []
|
||||
return documents.map((doc) => ({
|
||||
id: doc.id,
|
||||
label: doc.filename,
|
||||
}))
|
||||
},
|
||||
fetchById: async ({ context, detailId }: SelectorQueryArgs) => {
|
||||
if (!detailId) return null
|
||||
const knowledgeBaseId = ensureKnowledgeBase(context)
|
||||
const data = await fetchJson<{ data?: { document?: { id: string; filename: string } } }>(
|
||||
`/api/knowledge/${knowledgeBaseId}/documents/${detailId}`,
|
||||
{
|
||||
searchParams: { includeDisabled: 'true' },
|
||||
}
|
||||
)
|
||||
const doc = data.data?.document
|
||||
if (!doc) return null
|
||||
return { id: doc.id, label: doc.filename }
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export function getSelectorDefinition(key: SelectorKey): SelectorDefinition {
|
||||
const definition = registry[key]
|
||||
if (!definition) {
|
||||
throw new Error(`Missing selector definition for ${key}`)
|
||||
}
|
||||
return definition
|
||||
}
|
||||
|
||||
export function mergeOption(options: SelectorOption[], option?: SelectorOption | null) {
|
||||
if (!option) return options
|
||||
if (options.some((item) => item.id === option.id)) {
|
||||
return options
|
||||
}
|
||||
return [option, ...options]
|
||||
}
|
||||
172
apps/sim/hooks/selectors/resolution.ts
Normal file
172
apps/sim/hooks/selectors/resolution.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types'
|
||||
|
||||
export interface SelectorResolution {
|
||||
key: SelectorKey | null
|
||||
context: SelectorContext
|
||||
allowSearch: boolean
|
||||
}
|
||||
|
||||
export interface SelectorResolutionArgs {
|
||||
workflowId?: string
|
||||
credentialId?: string
|
||||
domain?: string
|
||||
projectId?: string
|
||||
planId?: string
|
||||
teamId?: string
|
||||
knowledgeBaseId?: string
|
||||
}
|
||||
|
||||
const defaultContext: SelectorContext = {}
|
||||
|
||||
export function resolveSelectorForSubBlock(
|
||||
subBlock: SubBlockConfig,
|
||||
args: SelectorResolutionArgs
|
||||
): SelectorResolution | null {
|
||||
switch (subBlock.type) {
|
||||
case 'file-selector':
|
||||
return resolveFileSelector(subBlock, args)
|
||||
case 'folder-selector':
|
||||
return resolveFolderSelector(subBlock, args)
|
||||
case 'channel-selector':
|
||||
return resolveChannelSelector(subBlock, args)
|
||||
case 'project-selector':
|
||||
return resolveProjectSelector(subBlock, args)
|
||||
case 'document-selector':
|
||||
return resolveDocumentSelector(subBlock, args)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function buildBaseContext(
|
||||
args: SelectorResolutionArgs,
|
||||
extra?: Partial<SelectorContext>
|
||||
): SelectorContext {
|
||||
return {
|
||||
...defaultContext,
|
||||
workflowId: args.workflowId,
|
||||
credentialId: args.credentialId,
|
||||
domain: args.domain,
|
||||
projectId: args.projectId,
|
||||
planId: args.planId,
|
||||
teamId: args.teamId,
|
||||
knowledgeBaseId: args.knowledgeBaseId,
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
function resolveFileSelector(
|
||||
subBlock: SubBlockConfig,
|
||||
args: SelectorResolutionArgs
|
||||
): SelectorResolution {
|
||||
const context = buildBaseContext(args, {
|
||||
mimeType: subBlock.mimeType,
|
||||
})
|
||||
|
||||
const provider = subBlock.provider || subBlock.serviceId || ''
|
||||
|
||||
switch (provider) {
|
||||
case 'google-calendar':
|
||||
return { key: 'google.calendar', context, allowSearch: false }
|
||||
case 'confluence':
|
||||
return { key: 'confluence.pages', context, allowSearch: true }
|
||||
case 'jira':
|
||||
return { key: 'jira.issues', context, allowSearch: true }
|
||||
case 'microsoft-teams':
|
||||
return { key: 'microsoft.teams', context, allowSearch: true }
|
||||
case 'wealthbox':
|
||||
return { key: 'wealthbox.contacts', context, allowSearch: true }
|
||||
case 'microsoft-planner':
|
||||
return { key: 'microsoft.planner', context, allowSearch: true }
|
||||
case 'microsoft-excel':
|
||||
return { key: 'microsoft.excel', context, allowSearch: true }
|
||||
case 'microsoft-word':
|
||||
return { key: 'microsoft.word', context, allowSearch: true }
|
||||
case 'google-drive':
|
||||
return { key: 'google.drive', context, allowSearch: true }
|
||||
case 'google-sheets':
|
||||
return { key: 'google.drive', context, allowSearch: true }
|
||||
case 'google-docs':
|
||||
return { key: 'google.drive', context, allowSearch: true }
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
if (subBlock.serviceId === 'onedrive') {
|
||||
const key: SelectorKey = subBlock.mimeType === 'file' ? 'onedrive.files' : 'onedrive.folders'
|
||||
return { key, context, allowSearch: true }
|
||||
}
|
||||
|
||||
if (subBlock.serviceId === 'sharepoint') {
|
||||
return { key: 'sharepoint.sites', context, allowSearch: true }
|
||||
}
|
||||
|
||||
if (subBlock.serviceId === 'google-sheets') {
|
||||
return { key: 'google.drive', context, allowSearch: true }
|
||||
}
|
||||
|
||||
return { key: null, context, allowSearch: true }
|
||||
}
|
||||
|
||||
function resolveFolderSelector(
|
||||
subBlock: SubBlockConfig,
|
||||
args: SelectorResolutionArgs
|
||||
): SelectorResolution {
|
||||
const provider = (subBlock.provider || subBlock.serviceId || 'gmail').toLowerCase()
|
||||
const key: SelectorKey = provider === 'outlook' ? 'outlook.folders' : 'gmail.labels'
|
||||
return {
|
||||
key,
|
||||
context: buildBaseContext(args),
|
||||
allowSearch: true,
|
||||
}
|
||||
}
|
||||
|
||||
function resolveChannelSelector(
|
||||
subBlock: SubBlockConfig,
|
||||
args: SelectorResolutionArgs
|
||||
): SelectorResolution {
|
||||
const provider = subBlock.provider || 'slack'
|
||||
if (provider !== 'slack') {
|
||||
return { key: null, context: buildBaseContext(args), allowSearch: true }
|
||||
}
|
||||
return {
|
||||
key: 'slack.channels',
|
||||
context: buildBaseContext(args),
|
||||
allowSearch: true,
|
||||
}
|
||||
}
|
||||
|
||||
function resolveProjectSelector(
|
||||
subBlock: SubBlockConfig,
|
||||
args: SelectorResolutionArgs
|
||||
): SelectorResolution {
|
||||
const provider = subBlock.provider || 'jira'
|
||||
const context = buildBaseContext(args)
|
||||
|
||||
if (provider === 'linear') {
|
||||
const key: SelectorKey = subBlock.id === 'teamId' ? 'linear.teams' : 'linear.projects'
|
||||
return {
|
||||
key,
|
||||
context,
|
||||
allowSearch: true,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
key: 'jira.projects',
|
||||
context,
|
||||
allowSearch: true,
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDocumentSelector(
|
||||
_subBlock: SubBlockConfig,
|
||||
args: SelectorResolutionArgs
|
||||
): SelectorResolution {
|
||||
return {
|
||||
key: 'knowledge.documents',
|
||||
context: buildBaseContext(args),
|
||||
allowSearch: true,
|
||||
}
|
||||
}
|
||||
61
apps/sim/hooks/selectors/types.ts
Normal file
61
apps/sim/hooks/selectors/types.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import type React from 'react'
|
||||
import type { QueryKey } from '@tanstack/react-query'
|
||||
|
||||
export type SelectorKey =
|
||||
| 'slack.channels'
|
||||
| 'gmail.labels'
|
||||
| 'outlook.folders'
|
||||
| 'google.calendar'
|
||||
| 'jira.issues'
|
||||
| 'jira.projects'
|
||||
| 'linear.projects'
|
||||
| 'linear.teams'
|
||||
| 'confluence.pages'
|
||||
| 'microsoft.teams'
|
||||
| 'wealthbox.contacts'
|
||||
| 'onedrive.files'
|
||||
| 'onedrive.folders'
|
||||
| 'sharepoint.sites'
|
||||
| 'microsoft.excel'
|
||||
| 'microsoft.word'
|
||||
| 'microsoft.planner'
|
||||
| 'google.drive'
|
||||
| 'knowledge.documents'
|
||||
|
||||
export interface SelectorOption {
|
||||
id: string
|
||||
label: string
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
meta?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface SelectorContext {
|
||||
workspaceId?: string
|
||||
workflowId?: string
|
||||
credentialId?: string
|
||||
provider?: string
|
||||
serviceId?: string
|
||||
domain?: string
|
||||
teamId?: string
|
||||
projectId?: string
|
||||
knowledgeBaseId?: string
|
||||
planId?: string
|
||||
mimeType?: string
|
||||
fileId?: string
|
||||
}
|
||||
|
||||
export interface SelectorQueryArgs {
|
||||
key: SelectorKey
|
||||
context: SelectorContext
|
||||
search?: string
|
||||
detailId?: string
|
||||
}
|
||||
|
||||
export interface SelectorDefinition {
|
||||
key: SelectorKey
|
||||
getQueryKey: (args: SelectorQueryArgs) => QueryKey
|
||||
fetchList: (args: SelectorQueryArgs) => Promise<SelectorOption[]>
|
||||
fetchById?: (args: SelectorQueryArgs) => Promise<SelectorOption | null>
|
||||
enabled?: (args: SelectorQueryArgs) => boolean
|
||||
staleTime?: number
|
||||
}
|
||||
61
apps/sim/hooks/selectors/use-selector-query.ts
Normal file
61
apps/sim/hooks/selectors/use-selector-query.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getSelectorDefinition, mergeOption } from './registry'
|
||||
import type { SelectorKey, SelectorOption, SelectorQueryArgs } from './types'
|
||||
|
||||
interface SelectorHookArgs extends Omit<SelectorQueryArgs, 'key'> {
|
||||
search?: string
|
||||
detailId?: string
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export function useSelectorOptions(key: SelectorKey, args: SelectorHookArgs) {
|
||||
const definition = getSelectorDefinition(key)
|
||||
const queryArgs: SelectorQueryArgs = {
|
||||
key,
|
||||
context: args.context,
|
||||
search: args.search,
|
||||
}
|
||||
const isEnabled = args.enabled ?? (definition.enabled ? definition.enabled(queryArgs) : true)
|
||||
return useQuery<SelectorOption[]>({
|
||||
queryKey: definition.getQueryKey(queryArgs),
|
||||
queryFn: () => definition.fetchList(queryArgs),
|
||||
enabled: isEnabled,
|
||||
staleTime: definition.staleTime ?? 30_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useSelectorOptionDetail(
|
||||
key: SelectorKey,
|
||||
args: SelectorHookArgs & { detailId?: string }
|
||||
) {
|
||||
const definition = getSelectorDefinition(key)
|
||||
const queryArgs: SelectorQueryArgs = {
|
||||
key,
|
||||
context: args.context,
|
||||
detailId: args.detailId,
|
||||
}
|
||||
const baseEnabled =
|
||||
Boolean(args.detailId) && definition.fetchById !== undefined
|
||||
? definition.enabled
|
||||
? definition.enabled(queryArgs)
|
||||
: true
|
||||
: false
|
||||
const enabled = args.enabled ?? baseEnabled
|
||||
|
||||
const query = useQuery<SelectorOption | null>({
|
||||
queryKey: [...definition.getQueryKey(queryArgs), 'detail', args.detailId ?? 'none'],
|
||||
queryFn: () => definition.fetchById!(queryArgs),
|
||||
enabled,
|
||||
staleTime: definition.staleTime ?? 300_000,
|
||||
})
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
export function useSelectorOptionMap(options: SelectorOption[], extra?: SelectorOption | null) {
|
||||
return useMemo(() => {
|
||||
const merged = mergeOption(options, extra)
|
||||
return new Map(merged.map((option) => [option.id, option]))
|
||||
}, [options, extra])
|
||||
}
|
||||
@@ -31,7 +31,6 @@ export function useCollaborativeWorkflow() {
|
||||
const moveHandler = (e: any) => {
|
||||
const { blockId, before, after } = e.detail || {}
|
||||
if (!blockId || !before || !after) return
|
||||
// Don't record moves during undo/redo operations
|
||||
if (isUndoRedoInProgress.current) return
|
||||
undoRedo.recordMove(blockId, before, after)
|
||||
}
|
||||
@@ -40,7 +39,6 @@ export function useCollaborativeWorkflow() {
|
||||
const { blockId, oldParentId, newParentId, oldPosition, newPosition, affectedEdges } =
|
||||
e.detail || {}
|
||||
if (!blockId) return
|
||||
// Don't record during undo/redo operations
|
||||
if (isUndoRedoInProgress.current) return
|
||||
undoRedo.recordUpdateParent(
|
||||
blockId,
|
||||
@@ -1097,6 +1095,9 @@ export function useCollaborativeWorkflow() {
|
||||
// Generate operation ID for queue tracking
|
||||
const operationId = crypto.randomUUID()
|
||||
|
||||
// Get fresh activeWorkflowId from store to avoid stale closure
|
||||
const currentActiveWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
|
||||
|
||||
// Add to queue for retry mechanism
|
||||
addToQueue({
|
||||
id: operationId,
|
||||
@@ -1105,7 +1106,7 @@ export function useCollaborativeWorkflow() {
|
||||
target: 'subblock',
|
||||
payload: { blockId, subblockId, value },
|
||||
},
|
||||
workflowId: activeWorkflowId || '',
|
||||
workflowId: currentActiveWorkflowId || '',
|
||||
userId: session?.user?.id || 'unknown',
|
||||
})
|
||||
|
||||
@@ -1134,15 +1135,7 @@ export function useCollaborativeWorkflow() {
|
||||
// Best-effort; do not block on clearing
|
||||
}
|
||||
},
|
||||
[
|
||||
subBlockStore,
|
||||
currentWorkflowId,
|
||||
activeWorkflowId,
|
||||
addToQueue,
|
||||
session?.user?.id,
|
||||
isShowingDiff,
|
||||
isInActiveRoom,
|
||||
]
|
||||
[subBlockStore, currentWorkflowId, addToQueue, session?.user?.id, isShowingDiff, isInActiveRoom]
|
||||
)
|
||||
|
||||
// Immediate tag selection (uses queue but processes immediately, no debouncing)
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useDisplayNamesStore } from '@/stores/display-names/store'
|
||||
|
||||
/**
|
||||
* Hook to get display name for a credential ID
|
||||
* Automatically fetches if not cached
|
||||
*/
|
||||
export function useCredentialDisplay(credentialId: string | undefined, provider?: string) {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
// Select the actual cached value from the store (not just the getter)
|
||||
// This ensures the component re-renders when the cache is populated
|
||||
const displayName = useDisplayNamesStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (!credentialId || !provider) return null
|
||||
return state.cache.credentials[provider]?.[credentialId] || null
|
||||
},
|
||||
[credentialId, provider]
|
||||
)
|
||||
)
|
||||
|
||||
// Fetch if not cached
|
||||
useEffect(() => {
|
||||
if (!credentialId || !provider || displayName || isLoading) return
|
||||
|
||||
setIsLoading(true)
|
||||
fetch(`/api/auth/oauth/credentials?provider=${encodeURIComponent(provider)}`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.credentials) {
|
||||
const credentialMap = data.credentials.reduce(
|
||||
(acc: Record<string, string>, cred: { id: string; name: string }) => {
|
||||
acc[cred.id] = cred.name
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
useDisplayNamesStore.getState().setDisplayNames('credentials', provider, credentialMap)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Silently fail
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false)
|
||||
})
|
||||
}, [credentialId, provider, displayName, isLoading])
|
||||
|
||||
return {
|
||||
displayName,
|
||||
isLoading,
|
||||
}
|
||||
}
|
||||
@@ -1,590 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { useDisplayNamesStore } from '@/stores/display-names/store'
|
||||
import { useKnowledgeStore } from '@/stores/knowledge/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
/**
|
||||
* Generic hook to get display name for any selector value
|
||||
* Automatically fetches if not cached
|
||||
*/
|
||||
export function useDisplayName(
|
||||
subBlock: SubBlockConfig | undefined,
|
||||
value: unknown,
|
||||
context?: {
|
||||
workspaceId?: string
|
||||
credentialId?: string
|
||||
provider?: string
|
||||
knowledgeBaseId?: string
|
||||
domain?: string
|
||||
teamId?: string
|
||||
projectId?: string
|
||||
planId?: string
|
||||
}
|
||||
): string | null {
|
||||
const getCachedKnowledgeBase = useKnowledgeStore((state) => state.getCachedKnowledgeBase)
|
||||
const getKnowledgeBase = useKnowledgeStore((state) => state.getKnowledgeBase)
|
||||
const getDocuments = useKnowledgeStore((state) => state.getDocuments)
|
||||
const [isFetching, setIsFetching] = useState(false)
|
||||
|
||||
const cachedDisplayName = useDisplayNamesStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (!subBlock || !value || typeof value !== 'string') return null
|
||||
|
||||
// Channels
|
||||
if (subBlock.type === 'channel-selector' && context?.credentialId) {
|
||||
return state.cache.channels[context.credentialId]?.[value] || null
|
||||
}
|
||||
|
||||
// Workflows
|
||||
if (subBlock.id === 'workflowId') {
|
||||
return state.cache.workflows.global?.[value] || null
|
||||
}
|
||||
|
||||
// Files
|
||||
if (subBlock.type === 'file-selector' && context?.credentialId) {
|
||||
return state.cache.files[context.credentialId]?.[value] || null
|
||||
}
|
||||
|
||||
// Folders
|
||||
if (subBlock.type === 'folder-selector' && context?.credentialId) {
|
||||
return state.cache.folders[context.credentialId]?.[value] || null
|
||||
}
|
||||
|
||||
// Projects
|
||||
if (subBlock.type === 'project-selector' && context?.provider && context?.credentialId) {
|
||||
const projectContext = `${context.provider}-${context.credentialId}`
|
||||
return state.cache.projects[projectContext]?.[value] || null
|
||||
}
|
||||
|
||||
// Documents
|
||||
if (subBlock.type === 'document-selector' && context?.knowledgeBaseId) {
|
||||
return state.cache.documents[context.knowledgeBaseId]?.[value] || null
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
[subBlock, value, context?.credentialId, context?.provider, context?.knowledgeBaseId]
|
||||
)
|
||||
)
|
||||
|
||||
// Auto-fetch knowledge bases if needed
|
||||
useEffect(() => {
|
||||
if (
|
||||
subBlock?.type === 'knowledge-base-selector' &&
|
||||
typeof value === 'string' &&
|
||||
value &&
|
||||
!isFetching
|
||||
) {
|
||||
const kb = getCachedKnowledgeBase(value)
|
||||
if (!kb) {
|
||||
setIsFetching(true)
|
||||
getKnowledgeBase(value)
|
||||
.catch(() => {
|
||||
// Silently fail
|
||||
})
|
||||
.finally(() => {
|
||||
setIsFetching(false)
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [subBlock?.type, value, isFetching, getCachedKnowledgeBase, getKnowledgeBase])
|
||||
|
||||
// Auto-fetch documents if needed
|
||||
useEffect(() => {
|
||||
if (
|
||||
subBlock?.type === 'document-selector' &&
|
||||
context?.knowledgeBaseId &&
|
||||
typeof value === 'string' &&
|
||||
value &&
|
||||
!cachedDisplayName &&
|
||||
!isFetching
|
||||
) {
|
||||
setIsFetching(true)
|
||||
getDocuments(context.knowledgeBaseId)
|
||||
.then((docs) => {
|
||||
if (docs.length > 0) {
|
||||
const documentMap = docs.reduce<Record<string, string>>((acc, doc) => {
|
||||
acc[doc.id] = doc.filename
|
||||
return acc
|
||||
}, {})
|
||||
useDisplayNamesStore
|
||||
.getState()
|
||||
.setDisplayNames('documents', context.knowledgeBaseId!, documentMap)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Silently fail
|
||||
})
|
||||
.finally(() => {
|
||||
setIsFetching(false)
|
||||
})
|
||||
}
|
||||
}, [subBlock?.type, value, context?.knowledgeBaseId, cachedDisplayName, isFetching, getDocuments])
|
||||
|
||||
// Auto-fetch workflows if needed
|
||||
useEffect(() => {
|
||||
if (subBlock?.id !== 'workflowId' || typeof value !== 'string' || !value) return
|
||||
if (cachedDisplayName || isFetching) return
|
||||
|
||||
const workflows = useWorkflowRegistry.getState().workflows
|
||||
if (!workflows[value]) return
|
||||
|
||||
const workflowMap = Object.entries(workflows).reduce<Record<string, string>>(
|
||||
(acc, [id, workflow]) => {
|
||||
acc[id] = workflow.name || `Workflow ${id.slice(0, 8)}`
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
|
||||
useDisplayNamesStore.getState().setDisplayNames('workflows', 'global', workflowMap)
|
||||
}, [subBlock?.id, value, cachedDisplayName, isFetching])
|
||||
|
||||
// Auto-fetch channels if needed
|
||||
useEffect(() => {
|
||||
if (subBlock?.type !== 'channel-selector' || !context?.credentialId || !value) return
|
||||
if (cachedDisplayName || isFetching) return
|
||||
|
||||
setIsFetching(true)
|
||||
fetch('/api/tools/slack/channels', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ credential: context.credentialId }),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.channels) {
|
||||
const channelMap = data.channels.reduce(
|
||||
(acc: Record<string, string>, ch: { id: string; name: string }) => {
|
||||
acc[ch.id] = ch.name
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
useDisplayNamesStore
|
||||
.getState()
|
||||
.setDisplayNames('channels', context.credentialId!, channelMap)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Silently fail
|
||||
})
|
||||
.finally(() => {
|
||||
setIsFetching(false)
|
||||
})
|
||||
}, [subBlock?.type, value, context?.credentialId, cachedDisplayName, isFetching])
|
||||
|
||||
// Auto-fetch folders if needed (Gmail/Outlook)
|
||||
useEffect(() => {
|
||||
if (subBlock?.type !== 'folder-selector' || !context?.credentialId || !value) return
|
||||
if (cachedDisplayName || isFetching) return
|
||||
|
||||
setIsFetching(true)
|
||||
const provider = subBlock.provider || 'gmail'
|
||||
const apiEndpoint =
|
||||
provider === 'outlook'
|
||||
? `/api/tools/outlook/folders?credentialId=${context.credentialId}`
|
||||
: `/api/tools/gmail/labels?credentialId=${context.credentialId}`
|
||||
|
||||
fetch(apiEndpoint)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
const folderList = provider === 'outlook' ? data.folders : data.labels
|
||||
if (folderList) {
|
||||
const folderMap = folderList.reduce(
|
||||
(acc: Record<string, string>, folder: { id: string; name: string }) => {
|
||||
acc[folder.id] = folder.name
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
useDisplayNamesStore
|
||||
.getState()
|
||||
.setDisplayNames('folders', context.credentialId!, folderMap)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Silently fail
|
||||
})
|
||||
.finally(() => {
|
||||
setIsFetching(false)
|
||||
})
|
||||
}, [
|
||||
subBlock?.type,
|
||||
subBlock?.provider,
|
||||
value,
|
||||
context?.credentialId,
|
||||
cachedDisplayName,
|
||||
isFetching,
|
||||
])
|
||||
|
||||
// Auto-fetch projects if needed (Jira, Linear)
|
||||
useEffect(() => {
|
||||
if (
|
||||
subBlock?.type !== 'project-selector' ||
|
||||
!context?.credentialId ||
|
||||
!context?.provider ||
|
||||
!value
|
||||
)
|
||||
return
|
||||
if (cachedDisplayName || isFetching) return
|
||||
|
||||
const projectContext = `${context.provider}-${context.credentialId}`
|
||||
setIsFetching(true)
|
||||
|
||||
if (context.provider === 'jira' && context.domain && context.credentialId) {
|
||||
// Fetch access token then get project info
|
||||
fetch('/api/auth/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ credentialId: context.credentialId }),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((tokenData) => {
|
||||
if (!tokenData.accessToken) throw new Error('No access token')
|
||||
return fetch('/api/tools/jira/projects', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
domain: context.domain,
|
||||
accessToken: tokenData.accessToken,
|
||||
projectId: value,
|
||||
}),
|
||||
})
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.project) {
|
||||
useDisplayNamesStore
|
||||
.getState()
|
||||
.setDisplayNames('projects', projectContext, { [value as string]: data.project.name })
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setIsFetching(false))
|
||||
} else if (context.provider === 'linear' && context.teamId) {
|
||||
fetch('/api/tools/linear/projects', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ credential: context.credentialId, teamId: context.teamId }),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.projects) {
|
||||
const projectMap = data.projects.reduce(
|
||||
(acc: Record<string, string>, proj: { id: string; name: string }) => {
|
||||
acc[proj.id] = proj.name
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
useDisplayNamesStore.getState().setDisplayNames('projects', projectContext, projectMap)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setIsFetching(false))
|
||||
} else {
|
||||
setIsFetching(false)
|
||||
}
|
||||
}, [
|
||||
subBlock?.type,
|
||||
value,
|
||||
context?.credentialId,
|
||||
context?.provider,
|
||||
context?.domain,
|
||||
context?.teamId,
|
||||
])
|
||||
|
||||
// Auto-fetch files if needed (provider-specific)
|
||||
useEffect(() => {
|
||||
if (subBlock?.type !== 'file-selector' || !context?.credentialId || !value) return
|
||||
if (cachedDisplayName || isFetching) return
|
||||
|
||||
setIsFetching(true)
|
||||
const provider = subBlock.provider || context.provider
|
||||
const serviceId = subBlock.serviceId
|
||||
|
||||
// Google Calendar
|
||||
if (provider === 'google-calendar') {
|
||||
fetch(`/api/tools/google_calendar/calendars?credentialId=${context.credentialId}`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.calendars) {
|
||||
const calendarMap = data.calendars.reduce(
|
||||
(acc: Record<string, string>, cal: { id: string; summary: string }) => {
|
||||
acc[cal.id] = cal.summary
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
useDisplayNamesStore
|
||||
.getState()
|
||||
.setDisplayNames('files', context.credentialId!, calendarMap)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setIsFetching(false))
|
||||
}
|
||||
// Jira issues
|
||||
else if (provider === 'jira' && context.domain && context.projectId && context.credentialId) {
|
||||
// Fetch access token then get issue info
|
||||
fetch('/api/auth/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ credentialId: context.credentialId }),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((tokenData) => {
|
||||
if (!tokenData.accessToken) throw new Error('No access token')
|
||||
return fetch('/api/tools/jira/issues', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
domain: context.domain,
|
||||
accessToken: tokenData.accessToken,
|
||||
issueKeys: [value],
|
||||
}),
|
||||
})
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.issues?.[0]) {
|
||||
useDisplayNamesStore.getState().setDisplayNames('files', context.credentialId!, {
|
||||
[value as string]: data.issues[0].name,
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setIsFetching(false))
|
||||
}
|
||||
// Confluence pages
|
||||
else if (provider === 'confluence' && context.domain && context.credentialId) {
|
||||
// Fetch access token then get page info
|
||||
fetch('/api/auth/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ credentialId: context.credentialId }),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((tokenData) => {
|
||||
if (!tokenData.accessToken) throw new Error('No access token')
|
||||
return fetch('/api/tools/confluence/page', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
domain: context.domain,
|
||||
accessToken: tokenData.accessToken,
|
||||
pageId: value,
|
||||
}),
|
||||
})
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.id && data.title) {
|
||||
useDisplayNamesStore.getState().setDisplayNames('files', context.credentialId!, {
|
||||
[data.id]: data.title,
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setIsFetching(false))
|
||||
}
|
||||
// Microsoft Teams
|
||||
else if (provider === 'microsoft-teams' && context.credentialId) {
|
||||
fetch('/api/tools/microsoft-teams/teams', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ credential: context.credentialId }),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.teams) {
|
||||
const teamMap = data.teams.reduce(
|
||||
(acc: Record<string, string>, team: { id: string; displayName: string }) => {
|
||||
acc[team.id] = team.displayName
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
useDisplayNamesStore.getState().setDisplayNames('files', context.credentialId!, teamMap)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setIsFetching(false))
|
||||
}
|
||||
// Wealthbox
|
||||
else if (provider === 'wealthbox' && context.credentialId) {
|
||||
fetch(`/api/tools/wealthbox/items?credentialId=${context.credentialId}&type=contact`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.items) {
|
||||
const contactMap = data.items.reduce(
|
||||
(acc: Record<string, string>, item: { id: string; name: string }) => {
|
||||
acc[item.id] = item.name
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
useDisplayNamesStore
|
||||
.getState()
|
||||
.setDisplayNames('files', context.credentialId!, contactMap)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setIsFetching(false))
|
||||
}
|
||||
// OneDrive files
|
||||
else if (serviceId === 'onedrive' && subBlock.mimeType === 'file') {
|
||||
fetch(`/api/tools/onedrive/files?credentialId=${context.credentialId}`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.files) {
|
||||
const fileMap = data.files.reduce(
|
||||
(acc: Record<string, string>, file: { id: string; name: string }) => {
|
||||
acc[file.id] = file.name
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
useDisplayNamesStore.getState().setDisplayNames('files', context.credentialId!, fileMap)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setIsFetching(false))
|
||||
}
|
||||
// OneDrive folders
|
||||
else if (serviceId === 'onedrive' && subBlock.mimeType !== 'file') {
|
||||
fetch(`/api/tools/onedrive/folders?credentialId=${context.credentialId}`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.files) {
|
||||
const fileMap = data.files.reduce(
|
||||
(acc: Record<string, string>, file: { id: string; name: string }) => {
|
||||
acc[file.id] = file.name
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
useDisplayNamesStore.getState().setDisplayNames('files', context.credentialId!, fileMap)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setIsFetching(false))
|
||||
}
|
||||
// SharePoint sites
|
||||
else if (serviceId === 'sharepoint') {
|
||||
fetch(`/api/tools/sharepoint/sites?credentialId=${context.credentialId}`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.files) {
|
||||
const fileMap = data.files.reduce(
|
||||
(acc: Record<string, string>, file: { id: string; name: string }) => {
|
||||
acc[file.id] = file.name
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
useDisplayNamesStore.getState().setDisplayNames('files', context.credentialId!, fileMap)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setIsFetching(false))
|
||||
}
|
||||
// Microsoft Excel/Word
|
||||
else if (provider === 'microsoft-excel' || provider === 'microsoft-word') {
|
||||
fetch(`/api/auth/oauth/microsoft/files?credentialId=${context.credentialId}`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.files) {
|
||||
const fileMap = data.files.reduce(
|
||||
(acc: Record<string, string>, file: { id: string; name: string }) => {
|
||||
acc[file.id] = file.name
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
useDisplayNamesStore.getState().setDisplayNames('files', context.credentialId!, fileMap)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setIsFetching(false))
|
||||
}
|
||||
// Microsoft Planner tasks
|
||||
else if (provider === 'microsoft-planner' && context.planId) {
|
||||
fetch(
|
||||
`/api/tools/microsoft_planner/tasks?credentialId=${context.credentialId}&planId=${context.planId}`
|
||||
)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.tasks) {
|
||||
const taskMap = data.tasks.reduce(
|
||||
(acc: Record<string, string>, task: { id: string; title: string }) => {
|
||||
acc[task.id] = task.title
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
useDisplayNamesStore.getState().setDisplayNames('files', context.credentialId!, taskMap)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setIsFetching(false))
|
||||
}
|
||||
// Google Drive files/folders (fetch by ID since no list endpoint via Picker API)
|
||||
else if (
|
||||
(provider === 'google-drive' || subBlock.serviceId === 'google-drive') &&
|
||||
typeof value === 'string' &&
|
||||
value
|
||||
) {
|
||||
const queryParams = new URLSearchParams({
|
||||
credentialId: context.credentialId,
|
||||
fileId: value,
|
||||
})
|
||||
fetch(`/api/tools/drive/file?${queryParams.toString()}`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.file?.id && data.file.name) {
|
||||
useDisplayNamesStore
|
||||
.getState()
|
||||
.setDisplayNames('files', context.credentialId!, { [data.file.id]: data.file.name })
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setIsFetching(false))
|
||||
} else {
|
||||
setIsFetching(false)
|
||||
}
|
||||
}, [
|
||||
subBlock?.type,
|
||||
subBlock?.provider,
|
||||
subBlock?.serviceId,
|
||||
subBlock?.mimeType,
|
||||
value,
|
||||
context?.credentialId,
|
||||
context?.provider,
|
||||
context?.domain,
|
||||
context?.projectId,
|
||||
context?.teamId,
|
||||
context?.planId,
|
||||
])
|
||||
|
||||
if (!subBlock || !value || typeof value !== 'string') {
|
||||
return null
|
||||
}
|
||||
|
||||
// Credentials - handled separately by useCredentialDisplay
|
||||
if (subBlock.type === 'oauth-input') {
|
||||
return null
|
||||
}
|
||||
|
||||
// Knowledge Bases - use existing knowledge store
|
||||
if (subBlock.type === 'knowledge-base-selector') {
|
||||
const kb = getCachedKnowledgeBase(value)
|
||||
return kb?.name || null
|
||||
}
|
||||
|
||||
// Return the cached display name (which triggers re-render when populated)
|
||||
return cachedDisplayName
|
||||
}
|
||||
22
apps/sim/hooks/use-knowledge-base-name.ts
Normal file
22
apps/sim/hooks/use-knowledge-base-name.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useKnowledgeStore } from '@/stores/knowledge/store'
|
||||
|
||||
export function useKnowledgeBaseName(knowledgeBaseId?: string | null) {
|
||||
const getCachedKnowledgeBase = useKnowledgeStore((state) => state.getCachedKnowledgeBase)
|
||||
const getKnowledgeBase = useKnowledgeStore((state) => state.getKnowledgeBase)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const cached = knowledgeBaseId ? getCachedKnowledgeBase(knowledgeBaseId) : null
|
||||
|
||||
useEffect(() => {
|
||||
if (!knowledgeBaseId || cached || isLoading) return
|
||||
setIsLoading(true)
|
||||
getKnowledgeBase(knowledgeBaseId)
|
||||
.catch(() => {
|
||||
// ignore
|
||||
})
|
||||
.finally(() => setIsLoading(false))
|
||||
}, [knowledgeBaseId, cached, isLoading, getKnowledgeBase])
|
||||
|
||||
return cached?.name ?? null
|
||||
}
|
||||
83
apps/sim/hooks/use-selector-display-name.ts
Normal file
83
apps/sim/hooks/use-selector-display-name.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { resolveSelectorForSubBlock } from '@/hooks/selectors/resolution'
|
||||
import type { SelectorKey } from '@/hooks/selectors/types'
|
||||
import {
|
||||
useSelectorOptionDetail,
|
||||
useSelectorOptionMap,
|
||||
useSelectorOptions,
|
||||
} from '@/hooks/selectors/use-selector-query'
|
||||
|
||||
interface SelectorDisplayNameArgs {
|
||||
subBlock?: SubBlockConfig
|
||||
value: unknown
|
||||
workflowId?: string
|
||||
credentialId?: string
|
||||
domain?: string
|
||||
projectId?: string
|
||||
planId?: string
|
||||
teamId?: string
|
||||
knowledgeBaseId?: string
|
||||
}
|
||||
|
||||
export function useSelectorDisplayName({
|
||||
subBlock,
|
||||
value,
|
||||
workflowId,
|
||||
credentialId,
|
||||
domain,
|
||||
projectId,
|
||||
planId,
|
||||
teamId,
|
||||
knowledgeBaseId,
|
||||
}: SelectorDisplayNameArgs) {
|
||||
const detailId = typeof value === 'string' && value.length > 0 ? value : undefined
|
||||
|
||||
const resolution = useMemo(() => {
|
||||
if (!subBlock || !detailId) return null
|
||||
return resolveSelectorForSubBlock(subBlock, {
|
||||
workflowId,
|
||||
credentialId,
|
||||
domain,
|
||||
projectId,
|
||||
planId,
|
||||
teamId,
|
||||
knowledgeBaseId,
|
||||
})
|
||||
}, [
|
||||
subBlock,
|
||||
detailId,
|
||||
workflowId,
|
||||
credentialId,
|
||||
domain,
|
||||
projectId,
|
||||
planId,
|
||||
teamId,
|
||||
knowledgeBaseId,
|
||||
])
|
||||
|
||||
const key = resolution?.key
|
||||
const context = resolution?.context ?? {}
|
||||
const enabled = Boolean(key && detailId)
|
||||
const resolvedKey: SelectorKey = (key ?? 'slack.channels') as SelectorKey
|
||||
const resolvedContext = enabled ? context : {}
|
||||
|
||||
const { data: options = [], isFetching: listLoading } = useSelectorOptions(resolvedKey, {
|
||||
context: resolvedContext,
|
||||
enabled,
|
||||
})
|
||||
|
||||
const { data: detailOption, isLoading: detailLoading } = useSelectorOptionDetail(resolvedKey, {
|
||||
context: resolvedContext,
|
||||
detailId: enabled ? detailId : undefined,
|
||||
enabled,
|
||||
})
|
||||
|
||||
const optionMap = useSelectorOptionMap(options, detailOption ?? undefined)
|
||||
const displayName = detailId ? (optionMap.get(detailId)?.label ?? null) : null
|
||||
|
||||
return {
|
||||
displayName: enabled ? displayName : null,
|
||||
isLoading: enabled ? listLoading || detailLoading : false,
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { idempotencyKey } from '@sim/db/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getRedisClient } from '@/lib/redis'
|
||||
import { extractProviderIdentifierFromBody } from '@/lib/webhooks/provider-utils'
|
||||
|
||||
const logger = createLogger('IdempotencyService')
|
||||
|
||||
@@ -451,13 +452,25 @@ export class IdempotencyService {
|
||||
|
||||
/**
|
||||
* Create an idempotency key from a webhook payload following RFC best practices
|
||||
* Standard webhook headers (webhook-id, x-webhook-id, etc.)
|
||||
* Checks both headers and body for unique identifiers to prevent duplicate executions
|
||||
*
|
||||
* @param webhookId - The webhook database ID
|
||||
* @param headers - HTTP headers from the webhook request
|
||||
* @param body - Parsed webhook body (optional, used for provider-specific identifiers)
|
||||
* @param provider - Provider name for body extraction (optional)
|
||||
* @returns A unique idempotency key for this webhook event
|
||||
*/
|
||||
static createWebhookIdempotencyKey(webhookId: string, headers?: Record<string, string>): string {
|
||||
static createWebhookIdempotencyKey(
|
||||
webhookId: string,
|
||||
headers?: Record<string, string>,
|
||||
body?: any,
|
||||
provider?: string
|
||||
): string {
|
||||
const normalizedHeaders = headers
|
||||
? Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v]))
|
||||
: undefined
|
||||
|
||||
// Check standard webhook headers first
|
||||
const webhookIdHeader =
|
||||
normalizedHeaders?.['webhook-id'] ||
|
||||
normalizedHeaders?.['x-webhook-id'] ||
|
||||
@@ -470,7 +483,22 @@ export class IdempotencyService {
|
||||
return `${webhookId}:${webhookIdHeader}`
|
||||
}
|
||||
|
||||
// Check body for provider-specific unique identifiers
|
||||
if (body && provider) {
|
||||
const bodyIdentifier = extractProviderIdentifierFromBody(provider, body)
|
||||
|
||||
if (bodyIdentifier) {
|
||||
return `${webhookId}:${bodyIdentifier}`
|
||||
}
|
||||
}
|
||||
|
||||
// No unique identifier found - generate random UUID
|
||||
// This means duplicate detection will not work for this webhook
|
||||
const uniqueId = randomUUID()
|
||||
logger.warn('No unique identifier found, duplicate executions may occur', {
|
||||
webhookId,
|
||||
provider,
|
||||
})
|
||||
return `${webhookId}:${uniqueId}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -906,6 +906,24 @@ export function parseProvider(provider: OAuthProvider): ProviderConfig {
|
||||
featureType: 'sharepoint',
|
||||
}
|
||||
}
|
||||
if (provider === 'microsoft-teams' || provider === 'microsoftteams') {
|
||||
return {
|
||||
baseProvider: 'microsoft',
|
||||
featureType: 'microsoft-teams',
|
||||
}
|
||||
}
|
||||
if (provider === 'microsoft-excel') {
|
||||
return {
|
||||
baseProvider: 'microsoft',
|
||||
featureType: 'microsoft-excel',
|
||||
}
|
||||
}
|
||||
if (provider === 'microsoft-planner') {
|
||||
return {
|
||||
baseProvider: 'microsoft',
|
||||
featureType: 'microsoft-planner',
|
||||
}
|
||||
}
|
||||
|
||||
// Handle compound providers (e.g., 'google-email' -> { baseProvider: 'google', featureType: 'email' })
|
||||
const [base, feature] = provider.split('-')
|
||||
|
||||
@@ -250,7 +250,7 @@ export async function verifyProviderAuth(
|
||||
const rawProviderConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
|
||||
const providerConfig = resolveProviderConfigEnvVars(rawProviderConfig, decryptedEnvVars)
|
||||
|
||||
if (foundWebhook.provider === 'microsoftteams') {
|
||||
if (foundWebhook.provider === 'microsoft-teams') {
|
||||
if (providerConfig.hmacSecret) {
|
||||
const authHeader = request.headers.get('authorization')
|
||||
|
||||
@@ -556,7 +556,7 @@ export async function checkRateLimits(
|
||||
traceSpans: [],
|
||||
})
|
||||
|
||||
if (foundWebhook.provider === 'microsoftteams') {
|
||||
if (foundWebhook.provider === 'microsoft-teams') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
type: 'message',
|
||||
@@ -634,7 +634,7 @@ export async function checkUsageLimits(
|
||||
traceSpans: [],
|
||||
})
|
||||
|
||||
if (foundWebhook.provider === 'microsoftteams') {
|
||||
if (foundWebhook.provider === 'microsoft-teams') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
type: 'message',
|
||||
@@ -783,7 +783,7 @@ export async function queueWebhookExecution(
|
||||
|
||||
// For Microsoft Teams Graph notifications, extract unique identifiers for idempotency
|
||||
if (
|
||||
foundWebhook.provider === 'microsoftteams' &&
|
||||
foundWebhook.provider === 'microsoft-teams' &&
|
||||
body?.value &&
|
||||
Array.isArray(body.value) &&
|
||||
body.value.length > 0
|
||||
@@ -835,7 +835,7 @@ export async function queueWebhookExecution(
|
||||
)
|
||||
}
|
||||
|
||||
if (foundWebhook.provider === 'microsoftteams') {
|
||||
if (foundWebhook.provider === 'microsoft-teams') {
|
||||
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
|
||||
const triggerId = providerConfig.triggerId as string | undefined
|
||||
|
||||
@@ -886,7 +886,7 @@ export async function queueWebhookExecution(
|
||||
} catch (error: any) {
|
||||
logger.error(`[${options.requestId}] Failed to queue webhook execution:`, error)
|
||||
|
||||
if (foundWebhook.provider === 'microsoftteams') {
|
||||
if (foundWebhook.provider === 'microsoft-teams') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
type: 'message',
|
||||
|
||||
85
apps/sim/lib/webhooks/provider-utils.ts
Normal file
85
apps/sim/lib/webhooks/provider-utils.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Provider-specific unique identifier extractors for webhook idempotency
|
||||
*/
|
||||
|
||||
function extractSlackIdentifier(body: any): string | null {
|
||||
if (body.event_id) {
|
||||
return body.event_id
|
||||
}
|
||||
|
||||
if (body.event?.ts && body.team_id) {
|
||||
return `${body.team_id}:${body.event.ts}`
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function extractTwilioIdentifier(body: any): string | null {
|
||||
return body.MessageSid || body.CallSid || null
|
||||
}
|
||||
|
||||
function extractStripeIdentifier(body: any): string | null {
|
||||
if (body.id && body.object === 'event') {
|
||||
return body.id
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function extractHubSpotIdentifier(body: any): string | null {
|
||||
if (Array.isArray(body) && body.length > 0 && body[0]?.eventId) {
|
||||
return String(body[0].eventId)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function extractLinearIdentifier(body: any): string | null {
|
||||
if (body.action && body.data?.id) {
|
||||
return `${body.action}:${body.data.id}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function extractJiraIdentifier(body: any): string | null {
|
||||
if (body.webhookEvent && (body.issue?.id || body.project?.id)) {
|
||||
return `${body.webhookEvent}:${body.issue?.id || body.project?.id}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function extractMicrosoftTeamsIdentifier(body: any): string | null {
|
||||
if (body.value && Array.isArray(body.value) && body.value.length > 0) {
|
||||
const notification = body.value[0]
|
||||
if (notification.subscriptionId && notification.resourceData?.id) {
|
||||
return `${notification.subscriptionId}:${notification.resourceData.id}`
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function extractAirtableIdentifier(body: any): string | null {
|
||||
if (body.cursor && typeof body.cursor === 'string') {
|
||||
return body.cursor
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const PROVIDER_EXTRACTORS: Record<string, (body: any) => string | null> = {
|
||||
slack: extractSlackIdentifier,
|
||||
twilio: extractTwilioIdentifier,
|
||||
twilio_voice: extractTwilioIdentifier,
|
||||
stripe: extractStripeIdentifier,
|
||||
hubspot: extractHubSpotIdentifier,
|
||||
linear: extractLinearIdentifier,
|
||||
jira: extractJiraIdentifier,
|
||||
'microsoft-teams': extractMicrosoftTeamsIdentifier,
|
||||
airtable: extractAirtableIdentifier,
|
||||
}
|
||||
|
||||
export function extractProviderIdentifierFromBody(provider: string, body: any): string | null {
|
||||
if (!body || typeof body !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const extractor = PROVIDER_EXTRACTORS[provider]
|
||||
return extractor ? extractor(body) : null
|
||||
}
|
||||
@@ -133,7 +133,7 @@ async function formatTeamsGraphNotification(
|
||||
input: 'Teams notification received',
|
||||
webhook: {
|
||||
data: {
|
||||
provider: 'microsoftteams',
|
||||
provider: 'microsoft-teams',
|
||||
path: foundWebhook?.path || '',
|
||||
providerConfig: foundWebhook?.providerConfig || {},
|
||||
payload: body,
|
||||
@@ -397,7 +397,7 @@ async function formatTeamsGraphNotification(
|
||||
},
|
||||
webhook: {
|
||||
data: {
|
||||
provider: 'microsoftteams',
|
||||
provider: 'microsoft-teams',
|
||||
path: foundWebhook?.path || '',
|
||||
providerConfig: foundWebhook?.providerConfig || {},
|
||||
payload: body,
|
||||
@@ -446,7 +446,7 @@ async function formatTeamsGraphNotification(
|
||||
},
|
||||
webhook: {
|
||||
data: {
|
||||
provider: 'microsoftteams',
|
||||
provider: 'microsoft-teams',
|
||||
path: foundWebhook?.path || '',
|
||||
providerConfig: foundWebhook?.providerConfig || {},
|
||||
payload: body,
|
||||
@@ -818,7 +818,7 @@ export async function formatWebhookInput(
|
||||
}
|
||||
}
|
||||
|
||||
if (foundWebhook.provider === 'microsoftteams') {
|
||||
if (foundWebhook.provider === 'microsoft-teams') {
|
||||
// Check if this is a Microsoft Graph change notification
|
||||
if (body?.value && Array.isArray(body.value) && body.value.length > 0) {
|
||||
return await formatTeamsGraphNotification(body, foundWebhook, foundWorkflow, request)
|
||||
@@ -875,7 +875,7 @@ export async function formatWebhookInput(
|
||||
|
||||
webhook: {
|
||||
data: {
|
||||
provider: 'microsoftteams',
|
||||
provider: 'microsoft-teams',
|
||||
path: foundWebhook.path,
|
||||
providerConfig: foundWebhook.providerConfig,
|
||||
payload: body,
|
||||
@@ -1653,7 +1653,7 @@ export function verifyProviderWebhook(
|
||||
|
||||
break
|
||||
}
|
||||
case 'microsoftteams':
|
||||
case 'microsoft-teams':
|
||||
break
|
||||
case 'generic':
|
||||
if (providerConfig.requireAuth) {
|
||||
|
||||
@@ -623,7 +623,7 @@ export async function cleanupExternalWebhook(
|
||||
): Promise<void> {
|
||||
if (webhook.provider === 'airtable') {
|
||||
await deleteAirtableWebhook(webhook, workflow, requestId)
|
||||
} else if (webhook.provider === 'microsoftteams') {
|
||||
} else if (webhook.provider === 'microsoft-teams') {
|
||||
await deleteTeamsSubscription(webhook, workflow, requestId)
|
||||
} else if (webhook.provider === 'telegram') {
|
||||
await deleteTelegramWebhook(webhook, requestId)
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
import { create } from 'zustand'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('DisplayNamesStore')
|
||||
|
||||
/**
|
||||
* Generic cache for ID-to-name mappings for all selector types
|
||||
* Structure: { type: { context: { id: name } } }
|
||||
*
|
||||
*/
|
||||
interface DisplayNamesCache {
|
||||
credentials: Record<string, Record<string, string>> // provider -> id -> name
|
||||
channels: Record<string, Record<string, string>> // credentialContext -> id -> name
|
||||
knowledgeBases: Record<string, Record<string, string>> // workspaceId -> id -> name
|
||||
workflows: Record<string, Record<string, string>> // always 'global' -> id -> name
|
||||
files: Record<string, Record<string, string>> // credentialContext -> id -> name
|
||||
folders: Record<string, Record<string, string>> // credentialContext -> id -> name
|
||||
projects: Record<string, Record<string, string>> // provider-credential -> id -> name
|
||||
documents: Record<string, Record<string, string>> // knowledgeBaseId -> id -> name
|
||||
}
|
||||
|
||||
interface DisplayNamesStore {
|
||||
cache: DisplayNamesCache
|
||||
|
||||
/**
|
||||
* Set a display name for an ID
|
||||
*/
|
||||
setDisplayName: (type: keyof DisplayNamesCache, context: string, id: string, name: string) => void
|
||||
|
||||
/**
|
||||
* Set multiple display names at once
|
||||
*/
|
||||
setDisplayNames: (
|
||||
type: keyof DisplayNamesCache,
|
||||
context: string,
|
||||
items: Record<string, string>
|
||||
) => void
|
||||
|
||||
/**
|
||||
* Get a display name for an ID
|
||||
*/
|
||||
getDisplayName: (type: keyof DisplayNamesCache, context: string, id: string) => string | null
|
||||
|
||||
/**
|
||||
* Remove a single display name
|
||||
*/
|
||||
removeDisplayName: (type: keyof DisplayNamesCache, context: string, id: string) => void
|
||||
|
||||
/**
|
||||
* Clear all cached display names for a type/context
|
||||
*/
|
||||
clearContext: (type: keyof DisplayNamesCache, context: string) => void
|
||||
|
||||
/**
|
||||
* Clear all cached display names
|
||||
*/
|
||||
clearAll: () => void
|
||||
}
|
||||
|
||||
const initialCache: DisplayNamesCache = {
|
||||
credentials: {},
|
||||
channels: {},
|
||||
knowledgeBases: {},
|
||||
workflows: {},
|
||||
files: {},
|
||||
folders: {},
|
||||
projects: {},
|
||||
documents: {},
|
||||
}
|
||||
|
||||
export const useDisplayNamesStore = create<DisplayNamesStore>((set, get) => ({
|
||||
cache: initialCache,
|
||||
|
||||
setDisplayName: (type, context, id, name) => {
|
||||
set((state) => ({
|
||||
cache: {
|
||||
...state.cache,
|
||||
[type]: {
|
||||
...state.cache[type],
|
||||
[context]: {
|
||||
...state.cache[type][context],
|
||||
[id]: name,
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
},
|
||||
|
||||
setDisplayNames: (type, context, items) => {
|
||||
set((state) => ({
|
||||
cache: {
|
||||
...state.cache,
|
||||
[type]: {
|
||||
...state.cache[type],
|
||||
[context]: {
|
||||
...state.cache[type][context],
|
||||
...items,
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
logger.info(`Cached ${Object.keys(items).length} display names`, { type, context })
|
||||
},
|
||||
|
||||
getDisplayName: (type, context, id) => {
|
||||
const contextCache = get().cache[type][context]
|
||||
return contextCache?.[id] || null
|
||||
},
|
||||
|
||||
removeDisplayName: (type, context, id) => {
|
||||
set((state) => {
|
||||
const contextCache = { ...state.cache[type][context] }
|
||||
delete contextCache[id]
|
||||
return {
|
||||
cache: {
|
||||
...state.cache,
|
||||
[type]: {
|
||||
...state.cache[type],
|
||||
[context]: contextCache,
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
clearContext: (type, context) => {
|
||||
set((state) => {
|
||||
const newTypeCache = { ...state.cache[type] }
|
||||
delete newTypeCache[context]
|
||||
return {
|
||||
cache: {
|
||||
...state.cache,
|
||||
[type]: newTypeCache,
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
clearAll: () => {
|
||||
set({ cache: initialCache })
|
||||
},
|
||||
}))
|
||||
@@ -13,7 +13,7 @@ import type {
|
||||
} from './types'
|
||||
|
||||
const logger = createLogger('UndoRedoStore')
|
||||
const DEFAULT_CAPACITY = 15
|
||||
const DEFAULT_CAPACITY = 100
|
||||
|
||||
function getStackKey(workflowId: string, userId: string): string {
|
||||
return `${workflowId}:${userId}`
|
||||
|
||||
Reference in New Issue
Block a user