Compare commits

...

31 Commits

Author SHA1 Message Date
Vikhyath Mondreti
8f0ef58056 v0.5.7: combobox selectors, usage indicator, workflow loading race condition, other improvements 2025-11-17 21:25:51 -08:00
Vikhyath Mondreti
33ca1483aa Merge branch 'main' into staging 2025-11-17 21:21:30 -08:00
Vikhyath Mondreti
620ce97056 improvement(selectors): consolidate all integration selectors to use the combobox (#2020)
* improvement(selectors): consolidate all integration selectors to use the combobox

* improved credential selector and file-upload styling to use emcn combobox

* update mcp subblocks to use emcn components, delete unused mcp server modal

* fix filterOptions change

* fix project selector

* attempted jira fix

* fix gdrive inf calls

* rewrite credential selector

* fix docs

* fix onedrive folder

* fix

* fix

* fix excel cred fetch

* fix excel part 2

---------

Co-authored-by: waleed <walif6@gmail.com>
2025-11-17 21:06:52 -08:00
Vikhyath Mondreti
25ac91779b fix(workflow-block): clearing child workflow input format field must lazy cascade parent workflow state deletion (#2038) 2025-11-17 19:00:50 -08:00
Waleed
d51a756c1b improvement(docs): remove copy page from mobile view on docs (#2037)
* improvement(docs): remove copy page from mobile view on docs

* bring title and pagenav lower on mobile

* added cursor pointer to clickable components in docs
2025-11-17 18:10:52 -08:00
Waleed
3d1feab507 improvement(undo-redo): expand undo-redo store to store 100 ops instead of 15 (#2036)
* improvement(undo-redo): expand undo-redo store to store 100 ops instead of 15

* prevent undo-redo from interfering with subblock browser text undo
2025-11-17 18:00:12 -08:00
Vikhyath Mondreti
98908dbfb9 fix(triggers): dedup + not surfacing deployment status log (#2033)
* fix(triggers): dedup + not surfacing deployment status log

* fix ms teams

* change to microsoftteams

* Revert "change to microsoftteams"

This reverts commit 217f808641.

* fix

* fix

* fix provider name

* fix oauth for msteams
2025-11-17 17:48:22 -08:00
Waleed
00d9b45a22 fix(workflows): fixed workflow loading in without start block, added templates RQ hook, cleaned up unused templates code (#2035) 2025-11-17 17:31:01 -08:00
Waleed
b5b2855b40 fix(overage): fix pill calculation in the usage indicator to be consistent across views (#2034) 2025-11-17 16:22:24 -08:00
Waleed
a81f3847df fix(usage-data): refetch on usage limit update in settings (#2032) 2025-11-17 15:07:52 -08:00
Waleed
6f3dee867c fix(notes): fix notes block spacing, additional logs for billing transfer route (#2029) 2025-11-17 13:37:17 -08:00
Siddharth Ganesan
bfa7c919d8 fix(response): fix response block http format (#2027)
* Fix response block

* Lint
2025-11-17 11:50:33 -08:00
Emir Karabeg
e37b01b92c improvement: code subblock, action bar, connections (#2024)
* improvement: action bar, connections

* fix: code block draggable resize
2025-11-17 11:04:41 -08:00
Siddharth Ganesan
7e3e38a6f2 fix(router): fix error edge in router block + fix source handle problem (#2019)
* Fix router block error port handling

* Remove comment

* Fix edge execution
2025-11-15 18:32:14 -08:00
Siddharth Ganesan
1c85fe9a51 fix(copilot): run workflow supports input format and fix run id (#2018) 2025-11-15 18:11:09 -08:00
Waleed
5f446ad756 feat(performance): added reactquery hooks for workflow operations, for logs, fixed logs reloading, fix subscription UI (#2017)
* feat(performance): added reactquery hooks for workflow operations, for logs, fixed logs reloading, fix subscription UI

* use useInfiniteQuery for logs fetching
2025-11-15 16:41:35 -08:00
Waleed
d99d5fe39c feat(billing): add notif for first failed payment, added upgrade email from free, updated providers that supported granular tool control to support them, fixed envvar popover, fixed redirect to wrong workspace after oauth connect (#2015)
* feat(billing): add notif for first failed payment, added upgrade email from free, updated providers that supported granular tool control to support them, fixed envvar popover, fixed redirect to wrong workspace after oauth connect

* fix build

* ack PR comments
2025-11-15 16:09:58 -08:00
Siddharth Ganesan
949f9287cf fix(variables): Fix resolution on double < (#2016)
* Fix variable <>

* Ling

* Clean
2025-11-15 15:09:01 -08:00
Siddharth Ganesan
fca92a7499 fix(tags): only show start block upstream if is ancestor (#2013) 2025-11-15 12:27:34 -08:00
Siddharth Ganesan
c25ea5c677 fix(triggers): disabled trigger shouldn't be added to dag (#2012)
* Fix disabled blocks

* Comments

* Fix api/chat trigger not found message
2025-11-15 12:19:37 -08:00
Siddharth Ganesan
dccd9e9ce5 fix(triggers): check triggermode and consolidate block type (#2011) 2025-11-15 12:00:12 -08:00
Waleed
b5d9964c48 feat(i18n): update translations (#2009) 2025-11-14 23:33:13 -08:00
Emir Karabeg
4bd0f31f36 improvement: runpath edges, blocks, active (#2008) 2025-11-14 23:26:41 -08:00
Waleed
f8070f9029 feat(models): added gpt-5.1 (#2007) 2025-11-14 23:23:47 -08:00
Waleed
bc8947caa6 fix(condition): treat condition input the same as the code subblock (#2006) 2025-11-14 23:23:39 -08:00
Waleed
f1111ec16f fix(modals): fix z-index for various modals and output selector and variables (#2005) 2025-11-14 23:13:31 -08:00
Waleed
d0767507b2 fix(pdfs): use unpdf instead of pdf-parse (#2004) 2025-11-14 22:39:28 -08:00
Waleed
8bd75debc1 fix(notes): fix notes, tighten spacing, update deprecated zustand function, update use mention data to ignore block positon (#2002) 2025-11-14 22:12:01 -08:00
Emir Karabeg
ad2a375358 fix(usage-indicator): conditional rendering, upgrade, and ui/ux (#2001)
* fix: usage-limit indicator and render conditonally on is billing enabled

* fix: upgrade render
2025-11-14 21:26:40 -08:00
Vikhyath Mondreti
de91dc97a9 test(pr): github trigger (#2000) 2025-11-14 18:08:07 -08:00
Vikhyath Mondreti
31ed712378 test(pr): hackathon (#1999) 2025-11-14 18:02:00 -08:00
80 changed files with 3247 additions and 10943 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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'
}`}
>

View File

@@ -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%)',

View File

@@ -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' />}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -441,7 +441,7 @@ export async function GET(request: NextRequest) {
})
}
case 'microsoftteams': {
case 'microsoft-teams': {
const hmacSecret = providerConfig.hmacSecret
if (!hmacSecret) {

View File

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

View File

@@ -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]' />

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 || [],

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 () => {

View File

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

View File

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

View 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
}
}

View 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,
}
}

View 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() })
},
})
}

View File

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

View 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
}

View 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]
}

View 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,
}
}

View 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
}

View 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])
}

View File

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

View File

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

View File

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

View 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
}

View 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,
}
}

View File

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

View File

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

View File

@@ -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',

View 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
}

View File

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

View File

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

View File

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

View File

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