mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 23:17:59 -05:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f0ef58056 | ||
|
|
33ca1483aa | ||
|
|
620ce97056 | ||
|
|
25ac91779b | ||
|
|
d51a756c1b | ||
|
|
3d1feab507 | ||
|
|
98908dbfb9 | ||
|
|
00d9b45a22 | ||
|
|
b5b2855b40 | ||
|
|
a81f3847df | ||
|
|
3058e35edf | ||
|
|
6f3dee867c | ||
|
|
bfa7c919d8 | ||
|
|
e37b01b92c | ||
|
|
7e3e38a6f2 | ||
|
|
1c85fe9a51 | ||
|
|
5f446ad756 | ||
|
|
d99d5fe39c | ||
|
|
949f9287cf | ||
|
|
fca92a7499 | ||
|
|
c25ea5c677 | ||
|
|
dccd9e9ce5 | ||
|
|
b5d9964c48 | ||
|
|
4bd0f31f36 | ||
|
|
f8070f9029 | ||
|
|
bc8947caa6 | ||
|
|
f1111ec16f | ||
|
|
d0767507b2 | ||
|
|
8bd75debc1 | ||
|
|
ad2a375358 | ||
|
|
de91dc97a9 | ||
|
|
31ed712378 |
@@ -198,15 +198,17 @@ export default async function Page(props: { params: Promise<{ slug?: string[]; l
|
||||
component: <CustomFooter />,
|
||||
}}
|
||||
>
|
||||
<div className='relative'>
|
||||
<div className='relative mt-6 sm:mt-0'>
|
||||
<div className='absolute top-1 right-0 flex items-center gap-2'>
|
||||
<CopyPageButton
|
||||
content={`# ${page.data.title}
|
||||
<div className='hidden sm:flex'>
|
||||
<CopyPageButton
|
||||
content={`# ${page.data.title}
|
||||
|
||||
${page.data.description || ''}
|
||||
|
||||
${page.data.content || ''}`}
|
||||
/>
|
||||
/>
|
||||
</div>
|
||||
<PageNavigationArrows previous={neighbours?.previous} next={neighbours?.next} />
|
||||
</div>
|
||||
<DocsTitle>{page.data.title}</DocsTitle>
|
||||
|
||||
@@ -69,7 +69,7 @@ export function SidebarFolder({
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className='rounded p-1 transition-colors hover:bg-gray-100/60 dark:hover:bg-gray-800/40'
|
||||
className='cursor-pointer rounded p-1 transition-colors hover:bg-gray-100/60 dark:hover:bg-gray-800/40'
|
||||
aria-label={open ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
<ChevronRight
|
||||
@@ -84,7 +84,7 @@ export function SidebarFolder({
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between rounded-md px-2.5 py-1.5 text-left font-medium text-[13px] leading-tight transition-colors',
|
||||
'flex w-full cursor-pointer items-center justify-between rounded-md px-2.5 py-1.5 text-left font-medium text-[13px] leading-tight transition-colors',
|
||||
'hover:bg-gray-100/60 dark:hover:bg-gray-800/40',
|
||||
'text-gray-800 dark:text-gray-200'
|
||||
)}
|
||||
|
||||
@@ -30,7 +30,7 @@ export function CodeBlock(props: React.ComponentProps<typeof FumadocsCodeBlock>)
|
||||
if (pre) handleCopy(pre.textContent || '')
|
||||
}}
|
||||
className={cn(
|
||||
'rounded-md p-2 transition-all',
|
||||
'cursor-pointer rounded-md p-2 transition-all',
|
||||
'border border-border bg-background/80 hover:bg-muted',
|
||||
'backdrop-blur-sm'
|
||||
)}
|
||||
|
||||
@@ -23,7 +23,7 @@ export function CopyPageButton({ content }: CopyPageButtonProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className='flex items-center gap-1.5 rounded-lg border border-border/40 bg-background px-2.5 py-2 text-muted-foreground/60 text-sm leading-none transition-all hover:border-border hover:bg-accent/50 hover:text-muted-foreground'
|
||||
className='flex cursor-pointer items-center gap-1.5 rounded-lg border border-border/40 bg-background px-2.5 py-2 text-muted-foreground/60 text-sm leading-none transition-all hover:border-border hover:bg-accent/50 hover:text-muted-foreground'
|
||||
aria-label={copied ? 'Copied to clipboard' : 'Copy page content'}
|
||||
>
|
||||
{copied ? (
|
||||
|
||||
@@ -82,7 +82,7 @@ export function LanguageDropdown() {
|
||||
aria-haspopup='listbox'
|
||||
aria-expanded={isOpen}
|
||||
aria-controls='language-menu'
|
||||
className='flex items-center gap-1.5 rounded-xl px-3 py-2 font-normal text-[0.9375rem] text-foreground/60 leading-[1.4] transition-colors hover:bg-foreground/8 hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring'
|
||||
className='flex cursor-pointer items-center gap-1.5 rounded-xl px-3 py-2 font-normal text-[0.9375rem] text-foreground/60 leading-[1.4] transition-colors hover:bg-foreground/8 hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring'
|
||||
style={{
|
||||
fontFamily:
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
||||
@@ -110,7 +110,7 @@ export function LanguageDropdown() {
|
||||
}}
|
||||
role='option'
|
||||
aria-selected={currentLang === code}
|
||||
className={`flex w-full items-center gap-3 px-3 py-3 text-base transition-colors first:rounded-t-xl last:rounded-b-xl hover:bg-muted/80 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring md:gap-2 md:px-2.5 md:py-2 md:text-sm ${
|
||||
className={`flex w-full cursor-pointer items-center gap-3 px-3 py-3 text-base transition-colors first:rounded-t-xl last:rounded-b-xl hover:bg-muted/80 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring md:gap-2 md:px-2.5 md:py-2 md:text-sm ${
|
||||
currentLang === code ? 'bg-muted/60 font-medium text-primary' : 'text-foreground'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -15,7 +15,7 @@ export function SearchTrigger() {
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
className='flex h-10 w-[460px] items-center gap-2 rounded-xl border border-border/50 px-3 py-2 text-sm backdrop-blur-xl transition-colors hover:border-border'
|
||||
className='flex h-10 w-[460px] cursor-pointer items-center gap-2 rounded-xl border border-border/50 px-3 py-2 text-sm backdrop-blur-xl transition-colors hover:border-border'
|
||||
style={{
|
||||
backgroundColor: 'hsla(0, 0%, 5%, 0.85)',
|
||||
backdropFilter: 'blur(33px) saturate(180%)',
|
||||
|
||||
@@ -14,7 +14,7 @@ export function ThemeToggle() {
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<button className='flex items-center justify-center rounded-md p-1 text-muted-foreground'>
|
||||
<button className='flex cursor-pointer items-center justify-center rounded-md p-1 text-muted-foreground'>
|
||||
<Moon className='h-4 w-4' />
|
||||
</button>
|
||||
)
|
||||
@@ -23,7 +23,7 @@ export function ThemeToggle() {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||||
className='flex items-center justify-center rounded-md p-1 text-muted-foreground transition-colors hover:text-foreground'
|
||||
className='flex cursor-pointer items-center justify-center rounded-md p-1 text-muted-foreground transition-colors hover:text-foreground'
|
||||
aria-label='Toggle theme'
|
||||
>
|
||||
{theme === 'dark' ? <Moon className='h-4 w-4' /> : <Sun className='h-4 w-4' />}
|
||||
|
||||
@@ -42,10 +42,10 @@ Der Benutzer-Prompt stellt die primären Eingabedaten für die Inferenzverarbeit
|
||||
|
||||
Der Agent-Block unterstützt mehrere LLM-Anbieter über eine einheitliche Inferenzschnittstelle. Verfügbare Modelle umfassen:
|
||||
|
||||
- **OpenAI**: GPT-5, GPT-4o, o1, o3, o4-mini, gpt-4.1
|
||||
- **Anthropic**: Claude 3.7 Sonnet
|
||||
- **OpenAI**: GPT-5.1, GPT-5, GPT-4o, o1, o3, o4-mini, gpt-4.1
|
||||
- **Anthropic**: Claude 4.5 Sonnet, Claude Opus 4.1
|
||||
- **Google**: Gemini 2.5 Pro, Gemini 2.0 Flash
|
||||
- **Andere Anbieter**: Groq, Cerebras, xAI, DeepSeek
|
||||
- **Andere Anbieter**: Groq, Cerebras, xAI, Azure OpenAI, OpenRouter
|
||||
- **Lokale Modelle**: Ollama-kompatible Modelle
|
||||
|
||||
### Temperatur
|
||||
|
||||
@@ -42,10 +42,10 @@ The user prompt represents the primary input data for inference processing. This
|
||||
|
||||
The Agent block supports multiple LLM providers through a unified inference interface. Available models include:
|
||||
|
||||
- **OpenAI**: GPT-5, GPT-4o, o1, o3, o4-mini, gpt-4.1
|
||||
- **Anthropic**: Claude 3.7 Sonnet
|
||||
- **OpenAI**: GPT-5.1, GPT-5, GPT-4o, o1, o3, o4-mini, gpt-4.1
|
||||
- **Anthropic**: Claude 4.5 Sonnet, Claude Opus 4.1
|
||||
- **Google**: Gemini 2.5 Pro, Gemini 2.0 Flash
|
||||
- **Other Providers**: Groq, Cerebras, xAI, DeepSeek
|
||||
- **Other Providers**: Groq, Cerebras, xAI, Azure OpenAI, OpenRouter
|
||||
- **Local Models**: Ollama-compatible models
|
||||
|
||||
### Temperature
|
||||
|
||||
@@ -42,11 +42,11 @@ El prompt del usuario representa los datos de entrada principales para el proces
|
||||
|
||||
El bloque Agente admite múltiples proveedores de LLM a través de una interfaz de inferencia unificada. Los modelos disponibles incluyen:
|
||||
|
||||
- **OpenAI**: GPT-5, GPT-4o, o1, o3, o4-mini, gpt-4.1
|
||||
- **Anthropic**: Claude 3.7 Sonnet
|
||||
- **OpenAI**: GPT-5.1, GPT-5, GPT-4o, o1, o3, o4-mini, gpt-4.1
|
||||
- **Anthropic**: Claude 4.5 Sonnet, Claude Opus 4.1
|
||||
- **Google**: Gemini 2.5 Pro, Gemini 2.0 Flash
|
||||
- **Otros proveedores**: Groq, Cerebras, xAI, DeepSeek
|
||||
- **Modelos locales**: Modelos compatibles con Ollama
|
||||
- **Otros proveedores**: Groq, Cerebras, xAI, Azure OpenAI, OpenRouter
|
||||
- **Modelos locales**: modelos compatibles con Ollama
|
||||
|
||||
### Temperatura
|
||||
|
||||
|
||||
@@ -42,10 +42,10 @@ Le prompt utilisateur représente les données d'entrée principales pour le tra
|
||||
|
||||
Le bloc Agent prend en charge plusieurs fournisseurs de LLM via une interface d'inférence unifiée. Les modèles disponibles comprennent :
|
||||
|
||||
- **OpenAI** : GPT-5, GPT-4o, o1, o3, o4-mini, gpt-4.1
|
||||
- **Anthropic** : Claude 3.7 Sonnet
|
||||
- **OpenAI** : GPT-5.1, GPT-5, GPT-4o, o1, o3, o4-mini, gpt-4.1
|
||||
- **Anthropic** : Claude 4.5 Sonnet, Claude Opus 4.1
|
||||
- **Google** : Gemini 2.5 Pro, Gemini 2.0 Flash
|
||||
- **Autres fournisseurs** : Groq, Cerebras, xAI, DeepSeek
|
||||
- **Autres fournisseurs** : Groq, Cerebras, xAI, Azure OpenAI, OpenRouter
|
||||
- **Modèles locaux** : modèles compatibles avec Ollama
|
||||
|
||||
### Température
|
||||
|
||||
@@ -42,10 +42,10 @@ When responding to questions about investments, include risk disclaimers.
|
||||
|
||||
エージェントブロックは統一された推論インターフェースを通じて複数のLLMプロバイダーをサポートしています。利用可能なモデルには以下が含まれます:
|
||||
|
||||
- **OpenAI**: GPT-5、GPT-4o、o1、o3、o4-mini、gpt-4.1
|
||||
- **Anthropic**: Claude 3.7 Sonnet
|
||||
- **OpenAI**: GPT-5.1、GPT-5、GPT-4o、o1、o3、o4-mini、gpt-4.1
|
||||
- **Anthropic**: Claude 4.5 Sonnet、Claude Opus 4.1
|
||||
- **Google**: Gemini 2.5 Pro、Gemini 2.0 Flash
|
||||
- **その他のプロバイダー**: Groq、Cerebras、xAI、DeepSeek
|
||||
- **その他のプロバイダー**: Groq、Cerebras、xAI、Azure OpenAI、OpenRouter
|
||||
- **ローカルモデル**: Ollama互換モデル
|
||||
|
||||
### 温度
|
||||
|
||||
@@ -42,10 +42,10 @@ When responding to questions about investments, include risk disclaimers.
|
||||
|
||||
代理模块通过统一的推理接口支持多个 LLM 提供商。可用模型包括:
|
||||
|
||||
- **OpenAI**:GPT-5、GPT-4o、o1、o3、o4-mini、gpt-4.1
|
||||
- **Anthropic**:Claude 3.7 Sonnet
|
||||
- **OpenAI**:GPT-5.1、GPT-5、GPT-4o、o1、o3、o4-mini、gpt-4.1
|
||||
- **Anthropic**:Claude 4.5 Sonnet、Claude Opus 4.1
|
||||
- **Google**:Gemini 2.5 Pro、Gemini 2.0 Flash
|
||||
- **其他提供商**:Groq、Cerebras、xAI、DeepSeek
|
||||
- **其他提供商**:Groq、Cerebras、xAI、Azure OpenAI、OpenRouter
|
||||
- **本地模型**:兼容 Ollama 的模型
|
||||
|
||||
### 温度
|
||||
|
||||
@@ -5117,7 +5117,7 @@ checksums:
|
||||
content/9: e688b523909d6d6e9966c17892a18c96
|
||||
content/10: e50bd5107ca3410126cf0252b3c47eca
|
||||
content/11: d03d17960348dea95c6df8f46114bd0a
|
||||
content/12: 3850cfbd618a9d1c836fc7086da0f9b4
|
||||
content/12: 80da7e96414b75bb5b910c437bf7894a
|
||||
content/13: 6a7479225be3a7c7a42ba557ece50d03
|
||||
content/14: c64f9cd5168b3e592fe3341cbe1a41fe
|
||||
content/15: 87d6b6280da1c98b1bc291483459c8cf
|
||||
|
||||
@@ -20,7 +20,7 @@ interface NavProps {
|
||||
}
|
||||
|
||||
export default function Nav({ hideAuthButtons = false, variant = 'landing' }: NavProps = {}) {
|
||||
const [githubStars, setGithubStars] = useState('18k')
|
||||
const [githubStars, setGithubStars] = useState('18.5k')
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const [isLoginHovered, setIsLoginHovered] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { db } from '@sim/db'
|
||||
import { account } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { validateMicrosoftGraphId } from '@/lib/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/utils'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { getCredential, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -15,15 +12,10 @@ const logger = createLogger('MicrosoftFileAPI')
|
||||
export async function GET(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const credentialId = searchParams.get('credentialId')
|
||||
const fileId = searchParams.get('fileId')
|
||||
const workflowId = searchParams.get('workflowId') || undefined
|
||||
|
||||
if (!credentialId || !fileId) {
|
||||
return NextResponse.json({ error: 'Credential ID and File ID are required' }, { status: 400 })
|
||||
@@ -35,19 +27,27 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: fileIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
|
||||
const authz = await authorizeCredentialUse(request, {
|
||||
credentialId,
|
||||
workflowId,
|
||||
requireWorkflowIdForInternal: false,
|
||||
})
|
||||
|
||||
if (!credentials.length) {
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
const status = authz.error === 'Credential not found' ? 404 : 403
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status })
|
||||
}
|
||||
|
||||
const credential = await getCredential(requestId, credentialId, authz.credentialOwnerUserId)
|
||||
if (!credential) {
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const credential = credentials[0]
|
||||
|
||||
if (credential.userId !== session.user.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credentialId,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
)
|
||||
|
||||
if (!accessToken) {
|
||||
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { db } from '@sim/db'
|
||||
import { account } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { generateRequestId } from '@/lib/utils'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { getCredential, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -18,46 +15,39 @@ export async function GET(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
// Get the session
|
||||
const session = await getSession()
|
||||
|
||||
// Check if the user is authenticated
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthenticated request rejected`)
|
||||
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Get the credential ID from the query params
|
||||
const { searchParams } = new URL(request.url)
|
||||
const credentialId = searchParams.get('credentialId')
|
||||
const query = searchParams.get('query') || ''
|
||||
const workflowId = searchParams.get('workflowId') || undefined
|
||||
|
||||
if (!credentialId) {
|
||||
logger.warn(`[${requestId}] Missing credential ID`)
|
||||
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Get the credential from the database
|
||||
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
|
||||
const authz = await authorizeCredentialUse(request, {
|
||||
credentialId,
|
||||
workflowId,
|
||||
requireWorkflowIdForInternal: false,
|
||||
})
|
||||
|
||||
if (!credentials.length) {
|
||||
logger.warn(`[${requestId}] Credential not found`, { credentialId })
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
const status = authz.error === 'Credential not found' ? 404 : 403
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status })
|
||||
}
|
||||
|
||||
const credential = await getCredential(requestId, credentialId, authz.credentialOwnerUserId)
|
||||
if (!credential) {
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const credential = credentials[0]
|
||||
|
||||
// Check if the credential belongs to the user
|
||||
if (credential.userId !== session.user.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized credential access attempt`, {
|
||||
credentialUserId: credential.userId,
|
||||
requestUserId: session.user.id,
|
||||
})
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Refresh access token if needed using the utility function
|
||||
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credentialId,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
)
|
||||
|
||||
if (!accessToken) {
|
||||
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { render } from '@react-email/components'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import CareersConfirmationEmail from '@/components/emails/careers-confirmation-email'
|
||||
import CareersSubmissionEmail from '@/components/emails/careers-submission-email'
|
||||
import CareersConfirmationEmail from '@/components/emails/careers/careers-confirmation-email'
|
||||
import CareersSubmissionEmail from '@/components/emails/careers/careers-submission-email'
|
||||
import { sendEmail } from '@/lib/email/mailer'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { generateRequestId } from '@/lib/utils'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { db } from '@sim/db'
|
||||
import { webhook as webhookTable, workflow as workflowTable } from '@sim/db/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { and, eq, or } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { verifyCronAuth } from '@/lib/auth/internal'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
@@ -35,7 +35,15 @@ export async function GET(request: NextRequest) {
|
||||
})
|
||||
.from(webhookTable)
|
||||
.innerJoin(workflowTable, eq(webhookTable.workflowId, workflowTable.id))
|
||||
.where(and(eq(webhookTable.isActive, true), eq(webhookTable.provider, 'microsoftteams')))
|
||||
.where(
|
||||
and(
|
||||
eq(webhookTable.isActive, true),
|
||||
or(
|
||||
eq(webhookTable.provider, 'microsoft-teams'),
|
||||
eq(webhookTable.provider, 'microsoftteams')
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
`Found ${webhooksWithWorkflows.length} active Teams webhooks, checking for expiring subscriptions`
|
||||
|
||||
@@ -137,7 +137,7 @@ export async function POST(request: NextRequest) {
|
||||
const isCredentialBased = credentialBasedProviders.includes(provider)
|
||||
// Treat Microsoft Teams chat subscription as credential-based for path generation purposes
|
||||
const isMicrosoftTeamsChatSubscription =
|
||||
provider === 'microsoftteams' &&
|
||||
provider === 'microsoft-teams' &&
|
||||
typeof providerConfig === 'object' &&
|
||||
providerConfig?.triggerId === 'microsoftteams_chat_subscription'
|
||||
|
||||
@@ -297,7 +297,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
if (provider === 'microsoftteams') {
|
||||
if (provider === 'microsoft-teams') {
|
||||
const { createTeamsSubscription } = await import('@/lib/webhooks/webhook-helpers')
|
||||
logger.info(`[${requestId}] Creating Teams subscription before saving to database`)
|
||||
try {
|
||||
|
||||
@@ -441,7 +441,7 @@ export async function GET(request: NextRequest) {
|
||||
})
|
||||
}
|
||||
|
||||
case 'microsoftteams': {
|
||||
case 'microsoft-teams': {
|
||||
const hmacSecret = providerConfig.hmacSecret
|
||||
|
||||
if (!hmacSecret) {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { LoggingSession } from '@/lib/logs/execution/logging-session'
|
||||
import { generateRequestId } from '@/lib/utils'
|
||||
import {
|
||||
checkRateLimits,
|
||||
@@ -139,34 +137,10 @@ export async function POST(
|
||||
if (foundWebhook.blockId) {
|
||||
const blockExists = await blockExistsInDeployment(foundWorkflow.id, foundWebhook.blockId)
|
||||
if (!blockExists) {
|
||||
logger.warn(
|
||||
logger.info(
|
||||
`[${requestId}] Trigger block ${foundWebhook.blockId} not found in deployment for workflow ${foundWorkflow.id}`
|
||||
)
|
||||
|
||||
const executionId = uuidv4()
|
||||
const loggingSession = new LoggingSession(foundWorkflow.id, executionId, 'webhook', requestId)
|
||||
|
||||
const actorUserId = foundWorkflow.workspaceId
|
||||
? (await import('@/lib/workspaces/utils')).getWorkspaceBilledAccountUserId(
|
||||
foundWorkflow.workspaceId
|
||||
) || foundWorkflow.userId
|
||||
: foundWorkflow.userId
|
||||
|
||||
await loggingSession.safeStart({
|
||||
userId: actorUserId,
|
||||
workspaceId: foundWorkflow.workspaceId || '',
|
||||
variables: {},
|
||||
})
|
||||
|
||||
await loggingSession.safeCompleteWithError({
|
||||
error: {
|
||||
message: `Trigger block not deployed. The webhook trigger (block ${foundWebhook.blockId}) is not present in the deployed workflow. Please redeploy the workflow.`,
|
||||
stackTrace: undefined,
|
||||
},
|
||||
traceSpans: [],
|
||||
})
|
||||
|
||||
return new NextResponse('Trigger block not deployed', { status: 404 })
|
||||
return new NextResponse('Trigger block not found in deployment', { status: 404 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core'
|
||||
import { type ExecutionEvent, encodeSSEEvent } from '@/lib/workflows/executor/execution-events'
|
||||
import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
|
||||
import { createStreamingResponse } from '@/lib/workflows/streaming'
|
||||
import { createHttpResponseFromBlock, workflowHasResponseBlock } from '@/lib/workflows/utils'
|
||||
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
|
||||
import { type ExecutionMetadata, ExecutionSnapshot } from '@/executor/execution/snapshot'
|
||||
import type { StreamingExecution } from '@/executor/types'
|
||||
@@ -495,6 +496,11 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
loggingSession,
|
||||
})
|
||||
|
||||
const hasResponseBlock = workflowHasResponseBlock(result)
|
||||
if (hasResponseBlock) {
|
||||
return createHttpResponseFromBlock(result)
|
||||
}
|
||||
|
||||
const filteredResult = {
|
||||
success: result.success,
|
||||
output: result.output,
|
||||
|
||||
@@ -118,18 +118,18 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
logger.info(`[${requestId}] Creating workflow ${workflowId} for user ${session.user.id}`)
|
||||
|
||||
// Track workflow creation
|
||||
try {
|
||||
const { trackPlatformEvent } = await import('@/lib/telemetry/tracer')
|
||||
trackPlatformEvent('platform.workflow.created', {
|
||||
'workflow.id': workflowId,
|
||||
'workflow.name': name,
|
||||
'workflow.has_workspace': !!workspaceId,
|
||||
'workflow.has_folder': !!folderId,
|
||||
import('@/lib/telemetry/tracer')
|
||||
.then(({ trackPlatformEvent }) => {
|
||||
trackPlatformEvent('platform.workflow.created', {
|
||||
'workflow.id': workflowId,
|
||||
'workflow.name': name,
|
||||
'workflow.has_workspace': !!workspaceId,
|
||||
'workflow.has_folder': !!folderId,
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
// Silently fail
|
||||
})
|
||||
} catch (_e) {
|
||||
// Silently fail
|
||||
}
|
||||
|
||||
await db.insert(workflow).values({
|
||||
id: workflowId,
|
||||
|
||||
@@ -74,30 +74,6 @@
|
||||
animation: dash-animation 1.5s linear infinite !important;
|
||||
}
|
||||
|
||||
/**
|
||||
* Active block ring animation - cycles through gray tones using box-shadow
|
||||
*/
|
||||
@keyframes ring-pulse-colors {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 4px var(--surface-14);
|
||||
}
|
||||
33% {
|
||||
box-shadow: 0 0 0 4px var(--surface-12);
|
||||
}
|
||||
66% {
|
||||
box-shadow: 0 0 0 4px var(--surface-15);
|
||||
}
|
||||
}
|
||||
|
||||
.dark .animate-ring-pulse {
|
||||
animation: ring-pulse-colors 2s ease-in-out infinite !important;
|
||||
}
|
||||
|
||||
.light .animate-ring-pulse {
|
||||
animation: ring-pulse-colors 2s ease-in-out infinite !important;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dark color tokens - single source of truth for all colors (dark-only)
|
||||
*/
|
||||
@@ -135,6 +111,7 @@
|
||||
--border-strong: #d1d1d1;
|
||||
--divider: #e5e5e5;
|
||||
--border-muted: #eeeeee;
|
||||
--border-success: #d5d5d5;
|
||||
|
||||
/* Brand & state */
|
||||
--brand-400: #8e4cfb;
|
||||
@@ -250,6 +227,7 @@
|
||||
--border-strong: #303030;
|
||||
--divider: #393939;
|
||||
--border-muted: #424242;
|
||||
--border-success: #575757;
|
||||
|
||||
/* Brand & state */
|
||||
--brand-400: #8e4cfb;
|
||||
|
||||
@@ -34,9 +34,9 @@ import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getBaseUrl } from '@/lib/urls/utils'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { CredentialRequirement } from '@/lib/workflows/credential-extractor'
|
||||
import type { Template } from '@/app/templates/templates'
|
||||
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import { useStarTemplate, useTemplate } from '@/hooks/queries/templates'
|
||||
|
||||
const logger = createLogger('TemplateDetails')
|
||||
|
||||
@@ -52,16 +52,14 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
const workspaceId = isWorkspaceContext ? (params?.workspaceId as string) : null
|
||||
const { data: session } = useSession()
|
||||
|
||||
const [template, setTemplate] = useState<Template | null>(null)
|
||||
const { data: template, isLoading: loading } = useTemplate(templateId)
|
||||
const starTemplate = useStarTemplate()
|
||||
|
||||
const [currentUserOrgs, setCurrentUserOrgs] = useState<string[]>([])
|
||||
const [currentUserOrgRoles, setCurrentUserOrgRoles] = useState<
|
||||
Array<{ organizationId: string; role: string }>
|
||||
>([])
|
||||
const [isSuperUser, setIsSuperUser] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [isStarred, setIsStarred] = useState(false)
|
||||
const [starCount, setStarCount] = useState(0)
|
||||
const [isStarring, setIsStarring] = useState(false)
|
||||
const [isUsing, setIsUsing] = useState(false)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [isApproving, setIsApproving] = useState(false)
|
||||
@@ -76,29 +74,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
|
||||
const currentUserId = session?.user?.id || null
|
||||
|
||||
// Fetch template data on client side
|
||||
useEffect(() => {
|
||||
if (!templateId) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const fetchTemplate = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/templates/${templateId}`)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setTemplate(data.data)
|
||||
setIsStarred(data.data.isStarred || false)
|
||||
setStarCount(data.data.stars || 0)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching template:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchUserOrganizations = async () => {
|
||||
if (!currentUserId) return
|
||||
|
||||
@@ -134,12 +110,10 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
}
|
||||
}
|
||||
|
||||
fetchTemplate()
|
||||
fetchSuperUserStatus()
|
||||
fetchUserOrganizations()
|
||||
}, [templateId, currentUserId])
|
||||
}, [currentUserId])
|
||||
|
||||
// Fetch workspaces when user is logged in
|
||||
useEffect(() => {
|
||||
if (!currentUserId) return
|
||||
|
||||
@@ -149,7 +123,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
const response = await fetch('/api/workspaces')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
// Filter workspaces where user has write/admin permissions
|
||||
const availableWorkspaces = data.workspaces
|
||||
.filter((ws: any) => ws.permissions === 'write' || ws.permissions === 'admin')
|
||||
.map((ws: any) => ({
|
||||
@@ -169,7 +142,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
fetchWorkspaces()
|
||||
}, [currentUserId])
|
||||
|
||||
// Clean up URL when returning from login
|
||||
useEffect(() => {
|
||||
if (template && searchParams?.get('use') === 'true' && currentUserId) {
|
||||
if (isWorkspaceContext && workspaceId) {
|
||||
@@ -181,26 +153,20 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
}
|
||||
}, [searchParams, currentUserId, template, isWorkspaceContext, workspaceId, router])
|
||||
|
||||
// Check if user can edit template
|
||||
const canEditTemplate = (() => {
|
||||
if (!currentUserId || !template?.creator) return false
|
||||
|
||||
// For user creator profiles: must be the user themselves
|
||||
if (template.creator.referenceType === 'user') {
|
||||
return template.creator.referenceId === currentUserId
|
||||
}
|
||||
|
||||
// For organization creator profiles:
|
||||
if (template.creator.referenceType === 'organization' && template.creator.referenceId) {
|
||||
const isOrgMember = currentUserOrgs.includes(template.creator.referenceId)
|
||||
|
||||
// If template has a connected workflow, any org member with workspace access can edit
|
||||
if (template.workflowId) {
|
||||
return isOrgMember
|
||||
}
|
||||
|
||||
// If template is orphaned, only admin/owner can edit
|
||||
// We need to check the user's role in the organization
|
||||
const orgMembership = currentUserOrgRoles.find(
|
||||
(org) => org.organizationId === template.creator?.referenceId
|
||||
)
|
||||
@@ -212,7 +178,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
return false
|
||||
})()
|
||||
|
||||
// Check workspace access for connected workflow
|
||||
useEffect(() => {
|
||||
const checkWorkspaceAccess = async () => {
|
||||
if (!template?.workflowId || !currentUserId || !canEditTemplate) {
|
||||
@@ -227,7 +192,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
} else if (checkResponse.ok) {
|
||||
setHasWorkspaceAccess(true)
|
||||
} else {
|
||||
// Workflow doesn't exist
|
||||
setHasWorkspaceAccess(null)
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -319,32 +283,20 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
* @param event - The wheel event fired when the user scrolls over the preview area.
|
||||
*/
|
||||
const handleCanvasWheelCapture = (event: React.WheelEvent<HTMLDivElement>) => {
|
||||
// Allow pinch/zoom gestures (e.g., ctrl/cmd + wheel) to continue to the canvas.
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
return
|
||||
}
|
||||
|
||||
// Prevent React Flow from handling the wheel; let the page scroll naturally.
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const handleStarToggle = async () => {
|
||||
if (isStarring || !currentUserId) return
|
||||
if (!currentUserId || !template) return
|
||||
|
||||
setIsStarring(true)
|
||||
try {
|
||||
const method = isStarred ? 'DELETE' : 'POST'
|
||||
const response = await fetch(`/api/templates/${template.id}/star`, { method })
|
||||
|
||||
if (response.ok) {
|
||||
setIsStarred(!isStarred)
|
||||
setStarCount((prev) => (isStarred ? prev - 1 : prev + 1))
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error toggling star:', error)
|
||||
} finally {
|
||||
setIsStarring(false)
|
||||
}
|
||||
starTemplate.mutate({
|
||||
templateId: template.id,
|
||||
action: template.isStarred ? 'remove' : 'add',
|
||||
})
|
||||
}
|
||||
|
||||
const handleUseTemplate = () => {
|
||||
@@ -357,7 +309,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
return
|
||||
}
|
||||
|
||||
// In workspace context, use current workspace directly
|
||||
if (isWorkspaceContext && workspaceId) {
|
||||
handleWorkspaceSelectForUse(workspaceId)
|
||||
}
|
||||
@@ -366,7 +317,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
const handleEditTemplate = async () => {
|
||||
if (!currentUserId || !template) return
|
||||
|
||||
// In workspace context with existing workflow, navigate directly
|
||||
if (isWorkspaceContext && workspaceId && template.workflowId) {
|
||||
setIsEditing(true)
|
||||
try {
|
||||
@@ -381,10 +331,8 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
} finally {
|
||||
setIsEditing(false)
|
||||
}
|
||||
// If workflow doesn't exist, fall through to workspace selector
|
||||
}
|
||||
|
||||
// Check if workflow exists and user has access (global context)
|
||||
if (template.workflowId && !isWorkspaceContext) {
|
||||
setIsEditing(true)
|
||||
try {
|
||||
@@ -410,7 +358,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
}
|
||||
}
|
||||
|
||||
// Workflow doesn't exist - show workspace selector or use current workspace
|
||||
if (isWorkspaceContext && workspaceId) {
|
||||
handleWorkspaceSelectForEdit(workspaceId)
|
||||
} else {
|
||||
@@ -435,7 +382,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
|
||||
const { workflowId } = await response.json()
|
||||
|
||||
// Navigate to the new workflow with full page load
|
||||
window.location.href = `/workspace/${workspaceId}/w/${workflowId}`
|
||||
} catch (error) {
|
||||
logger.error('Error using template:', error)
|
||||
@@ -450,7 +396,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
setIsUsing(true)
|
||||
setShowWorkspaceSelectorForEdit(false)
|
||||
try {
|
||||
// Import template as a new workflow and connect it to the template
|
||||
const response = await fetch(`/api/templates/${template.id}/use`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -463,7 +408,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
|
||||
const { workflowId } = await response.json()
|
||||
|
||||
// Navigate to the new workflow with full page load
|
||||
window.location.href = `/workspace/${workspaceId}/w/${workflowId}`
|
||||
} catch (error) {
|
||||
logger.error('Error importing template for editing:', error)
|
||||
@@ -482,9 +426,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
// Update template status optimistically
|
||||
setTemplate({ ...template, status: 'approved' })
|
||||
// Redirect back to templates page after approval
|
||||
if (isWorkspaceContext && workspaceId) {
|
||||
router.push(`/workspace/${workspaceId}/templates`)
|
||||
} else {
|
||||
@@ -508,9 +449,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
// Update template status optimistically
|
||||
setTemplate({ ...template, status: 'rejected' })
|
||||
// Redirect back to templates page after rejection
|
||||
if (isWorkspaceContext && workspaceId) {
|
||||
router.push(`/workspace/${workspaceId}/templates`)
|
||||
} else {
|
||||
@@ -752,11 +690,11 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
onClick={handleStarToggle}
|
||||
className={cn(
|
||||
'h-[14px] w-[14px] cursor-pointer transition-colors',
|
||||
isStarred ? 'fill-yellow-500 text-yellow-500' : 'text-[#888888]',
|
||||
isStarring && 'opacity-50'
|
||||
template.isStarred ? 'fill-yellow-500 text-yellow-500' : 'text-[#888888]',
|
||||
starTemplate.isPending && 'opacity-50'
|
||||
)}
|
||||
/>
|
||||
<span className='font-medium text-[#888888] text-[14px]'>{starCount}</span>
|
||||
<span className='font-medium text-[#888888] text-[14px]'>{template.stars || 0}</span>
|
||||
|
||||
{/* Users icon and count */}
|
||||
<ChartNoAxesColumn className='h-[16px] w-[16px] text-[#888888]' />
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import { useStarTemplate } from '@/hooks/queries/templates'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('TemplateCard')
|
||||
@@ -12,37 +13,20 @@ const logger = createLogger('TemplateCard')
|
||||
interface TemplateCardProps {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
author: string
|
||||
authorImageUrl?: string | null
|
||||
usageCount: string
|
||||
stars?: number
|
||||
icon?: React.ReactNode | string
|
||||
iconColor?: string
|
||||
blocks?: string[]
|
||||
onClick?: () => void
|
||||
className?: string
|
||||
// Workflow state for rendering preview
|
||||
state?: WorkflowState
|
||||
isStarred?: boolean
|
||||
// Optional callback when template is successfully used (for closing modals, etc.)
|
||||
onTemplateUsed?: () => void
|
||||
// Callback when star state changes (for parent state updates)
|
||||
onStarChange?: (templateId: string, isStarred: boolean, newStarCount: number) => void
|
||||
// User authentication status
|
||||
isAuthenticated?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton component for loading states
|
||||
*/
|
||||
export function TemplateCardSkeleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={cn('h-[268px] w-full rounded-[8px] bg-[#202020] p-[8px]', className)}>
|
||||
{/* Workflow preview skeleton */}
|
||||
<div className='h-[180px] w-full animate-pulse rounded-[6px] bg-gray-700' />
|
||||
|
||||
{/* Title and blocks row skeleton */}
|
||||
<div className='mt-[14px] flex items-center justify-between'>
|
||||
<div className='h-4 w-32 animate-pulse rounded bg-gray-700' />
|
||||
<div className='flex items-center gap-[-4px]'>
|
||||
@@ -55,7 +39,6 @@ export function TemplateCardSkeleton({ className }: { className?: string }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Creator and stats row skeleton */}
|
||||
<div className='mt-[14px] flex items-center justify-between'>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<div className='h-[14px] w-[14px] animate-pulse rounded-full bg-gray-700' />
|
||||
@@ -72,31 +55,23 @@ export function TemplateCardSkeleton({ className }: { className?: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
// Utility function to extract block types from workflow state
|
||||
const extractBlockTypesFromState = (state?: {
|
||||
blocks?: Record<string, { type: string; name?: string }>
|
||||
}): string[] => {
|
||||
if (!state?.blocks) return []
|
||||
|
||||
// Get unique block types from the state, excluding starter blocks
|
||||
// Sort the keys to ensure consistent ordering between server and client
|
||||
const blockTypes = Object.keys(state.blocks)
|
||||
.sort() // Sort keys to ensure consistent order
|
||||
.sort()
|
||||
.map((key) => state.blocks![key].type)
|
||||
.filter((type) => type !== 'starter')
|
||||
return [...new Set(blockTypes)]
|
||||
}
|
||||
|
||||
// Utility function to get the full block config for colored icon display
|
||||
const getBlockConfig = (blockType: string) => {
|
||||
const block = getBlock(blockType)
|
||||
return block
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an arbitrary workflow-like object into a valid WorkflowState for preview rendering.
|
||||
* Ensures required fields exist: blocks with required properties, edges array, loops and parallels maps.
|
||||
*/
|
||||
function normalizeWorkflowState(input?: any): WorkflowState | null {
|
||||
if (!input || !input.blocks) return null
|
||||
|
||||
@@ -142,34 +117,22 @@ function normalizeWorkflowState(input?: any): WorkflowState | null {
|
||||
function TemplateCardInner({
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
author,
|
||||
authorImageUrl,
|
||||
usageCount,
|
||||
stars = 0,
|
||||
icon,
|
||||
iconColor = 'bg-blue-500',
|
||||
blocks = [],
|
||||
onClick,
|
||||
className,
|
||||
state,
|
||||
isStarred = false,
|
||||
onTemplateUsed,
|
||||
onStarChange,
|
||||
isAuthenticated = true,
|
||||
}: TemplateCardProps) {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
|
||||
// Local state for optimistic updates
|
||||
const [localIsStarred, setLocalIsStarred] = useState(isStarred)
|
||||
const [localStarCount, setLocalStarCount] = useState(stars)
|
||||
const [isStarLoading, setIsStarLoading] = useState(false)
|
||||
const { mutate: toggleStar, isPending: isStarLoading } = useStarTemplate()
|
||||
|
||||
// Memoize normalized workflow state to avoid recalculation on every render
|
||||
const normalizedState = useMemo(() => normalizeWorkflowState(state), [state])
|
||||
|
||||
// Use IntersectionObserver to defer rendering the heavy WorkflowPreview until in viewport
|
||||
const previewRef = useRef<HTMLDivElement | null>(null)
|
||||
const [isInView, setIsInView] = useState(false)
|
||||
|
||||
@@ -188,9 +151,6 @@ function TemplateCardInner({
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
// Extract block types from state if provided, otherwise use the blocks prop
|
||||
// Filter out starter blocks in both cases and sort for consistent rendering
|
||||
// Memoized to prevent recalculation on every render
|
||||
const blockTypes = useMemo(
|
||||
() =>
|
||||
state
|
||||
@@ -199,65 +159,16 @@ function TemplateCardInner({
|
||||
[state, blocks]
|
||||
)
|
||||
|
||||
// Handle star toggle with optimistic updates
|
||||
const handleStarClick = async (e: React.MouseEvent) => {
|
||||
const handleStarClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
|
||||
// Prevent multiple clicks while loading
|
||||
if (isStarLoading) return
|
||||
|
||||
setIsStarLoading(true)
|
||||
|
||||
// Optimistic update - update UI immediately
|
||||
const newIsStarred = !localIsStarred
|
||||
const newStarCount = newIsStarred ? localStarCount + 1 : localStarCount - 1
|
||||
|
||||
setLocalIsStarred(newIsStarred)
|
||||
setLocalStarCount(newStarCount)
|
||||
|
||||
// Notify parent component immediately for optimistic update
|
||||
if (onStarChange) {
|
||||
onStarChange(id, newIsStarred, newStarCount)
|
||||
}
|
||||
|
||||
try {
|
||||
const method = localIsStarred ? 'DELETE' : 'POST'
|
||||
const response = await fetch(`/api/templates/${id}/star`, { method })
|
||||
|
||||
if (!response.ok) {
|
||||
// Rollback on error
|
||||
setLocalIsStarred(localIsStarred)
|
||||
setLocalStarCount(localStarCount)
|
||||
|
||||
// Rollback parent state too
|
||||
if (onStarChange) {
|
||||
onStarChange(id, localIsStarred, localStarCount)
|
||||
}
|
||||
|
||||
logger.error('Failed to toggle star:', response.statusText)
|
||||
}
|
||||
} catch (error) {
|
||||
// Rollback on error
|
||||
setLocalIsStarred(localIsStarred)
|
||||
setLocalStarCount(localStarCount)
|
||||
|
||||
// Rollback parent state too
|
||||
if (onStarChange) {
|
||||
onStarChange(id, localIsStarred, localStarCount)
|
||||
}
|
||||
|
||||
logger.error('Error toggling star:', error)
|
||||
} finally {
|
||||
setIsStarLoading(false)
|
||||
}
|
||||
toggleStar({
|
||||
templateId: id,
|
||||
action: isStarred ? 'remove' : 'add',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate template detail page URL based on context.
|
||||
* If we're in a workspace context, navigate to the workspace template page.
|
||||
* Otherwise, navigate to the global template page.
|
||||
* Memoized to avoid recalculation on every render.
|
||||
*/
|
||||
const templateUrl = useMemo(() => {
|
||||
const workspaceId = params?.workspaceId as string | undefined
|
||||
if (workspaceId) {
|
||||
@@ -266,23 +177,8 @@ function TemplateCardInner({
|
||||
return `/templates/${id}`
|
||||
}, [params?.workspaceId, id])
|
||||
|
||||
/**
|
||||
* Handle use button click - navigate to template detail page
|
||||
*/
|
||||
const handleUseClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
router.push(templateUrl)
|
||||
},
|
||||
[router, templateUrl]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handle card click - navigate to template detail page
|
||||
*/
|
||||
const handleCardClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
// Don't navigate if clicking on action buttons
|
||||
const target = e.target as HTMLElement
|
||||
if (target.closest('button') || target.closest('[data-action]')) {
|
||||
return
|
||||
@@ -298,7 +194,6 @@ function TemplateCardInner({
|
||||
onClick={handleCardClick}
|
||||
className={cn('w-full cursor-pointer rounded-[8px] bg-[#202020] p-[8px]', className)}
|
||||
>
|
||||
{/* Workflow Preview */}
|
||||
<div
|
||||
ref={previewRef}
|
||||
className='pointer-events-none h-[180px] w-full overflow-hidden rounded-[6px]'
|
||||
@@ -318,16 +213,12 @@ function TemplateCardInner({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title and Blocks Row */}
|
||||
<div className='mt-[10px] flex items-center justify-between'>
|
||||
{/* Template Name */}
|
||||
<h3 className='truncate pr-[8px] pl-[2px] font-medium text-[16px] text-white'>{title}</h3>
|
||||
|
||||
{/* Block Icons */}
|
||||
<div className='flex flex-shrink-0'>
|
||||
{blockTypes.length > 4 ? (
|
||||
<>
|
||||
{/* Show first 3 blocks when there are more than 4 */}
|
||||
{blockTypes.slice(0, 3).map((blockType, index) => {
|
||||
const blockConfig = getBlockConfig(blockType)
|
||||
if (!blockConfig) return null
|
||||
@@ -345,7 +236,6 @@ function TemplateCardInner({
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{/* Show +n for remaining blocks */}
|
||||
<div
|
||||
className='flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-[4px] bg-[#4A4A4A]'
|
||||
style={{ marginLeft: '-4px' }}
|
||||
@@ -354,7 +244,6 @@ function TemplateCardInner({
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
/* Show all blocks when 4 or fewer */
|
||||
blockTypes.map((blockType, index) => {
|
||||
const blockConfig = getBlockConfig(blockType)
|
||||
if (!blockConfig) return null
|
||||
@@ -376,9 +265,7 @@ function TemplateCardInner({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Creator and Stats Row */}
|
||||
<div className='mt-[10px] flex items-center justify-between'>
|
||||
{/* Creator Info */}
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
{authorImageUrl ? (
|
||||
<div className='h-[26px] w-[26px] flex-shrink-0 overflow-hidden rounded-full'>
|
||||
@@ -392,7 +279,6 @@ function TemplateCardInner({
|
||||
<span className='truncate font-medium text-[#888888] text-[12px]'>{author}</span>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className='flex flex-shrink-0 items-center gap-[6px] font-medium text-[#888888] text-[12px]'>
|
||||
<User className='h-[12px] w-[12px]' />
|
||||
<span>{usageCount}</span>
|
||||
@@ -400,11 +286,11 @@ function TemplateCardInner({
|
||||
onClick={handleStarClick}
|
||||
className={cn(
|
||||
'h-[12px] w-[12px] cursor-pointer transition-colors',
|
||||
localIsStarred ? 'fill-yellow-500 text-yellow-500' : 'text-[#888888]',
|
||||
isStarred ? 'fill-yellow-500 text-yellow-500' : 'text-[#888888]',
|
||||
isStarLoading && 'opacity-50'
|
||||
)}
|
||||
/>
|
||||
<span>{localStarCount}</span>
|
||||
<span>{stars}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Input } from '@/components/ui/input'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { CredentialRequirement } from '@/lib/workflows/credential-extractor'
|
||||
import { TemplateCard, TemplateCardSkeleton } from '@/app/templates/components/template-card'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
import type { CreatorProfileDetails } from '@/types/creator-profile'
|
||||
|
||||
@@ -55,11 +56,11 @@ export default function Templates({
|
||||
}: TemplatesProps) {
|
||||
const router = useRouter()
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 300)
|
||||
const [activeTab, setActiveTab] = useState('gallery')
|
||||
const [templates, setTemplates] = useState<Template[]>(initialTemplates)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// Redirect authenticated users to workspace templates
|
||||
useEffect(() => {
|
||||
if (currentUserId) {
|
||||
const redirectToWorkspace = async () => {
|
||||
@@ -80,32 +81,19 @@ export default function Templates({
|
||||
}
|
||||
}, [currentUserId, router])
|
||||
|
||||
/**
|
||||
* Update star status for a template
|
||||
*/
|
||||
const handleStarChange = (templateId: string, isStarred: boolean, newStarCount: number) => {
|
||||
setTemplates((prevTemplates) =>
|
||||
prevTemplates.map((template) =>
|
||||
template.id === templateId ? { ...template, isStarred, stars: newStarCount } : template
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter templates based on active tab and search query
|
||||
* Memoized to prevent unnecessary recalculations on render
|
||||
*/
|
||||
const filteredTemplates = useMemo(() => {
|
||||
const query = searchQuery.toLowerCase()
|
||||
const query = debouncedSearchQuery.toLowerCase()
|
||||
|
||||
return templates.filter((template) => {
|
||||
// Filter by tab - only gallery and pending for public page
|
||||
const tabMatch =
|
||||
activeTab === 'gallery' ? template.status === 'approved' : template.status === 'pending'
|
||||
|
||||
if (!tabMatch) return false
|
||||
|
||||
// Filter by search query
|
||||
if (!query) return true
|
||||
|
||||
const searchableText = [template.name, template.details?.tagline, template.creator?.name]
|
||||
@@ -115,14 +103,14 @@ export default function Templates({
|
||||
|
||||
return searchableText.includes(query)
|
||||
})
|
||||
}, [templates, activeTab, searchQuery])
|
||||
}, [templates, activeTab, debouncedSearchQuery])
|
||||
|
||||
/**
|
||||
* Get empty state message based on current filters
|
||||
* Memoized to prevent unnecessary recalculations on render
|
||||
*/
|
||||
const emptyState = useMemo(() => {
|
||||
if (searchQuery) {
|
||||
if (debouncedSearchQuery) {
|
||||
return {
|
||||
title: 'No templates found',
|
||||
description: 'Try a different search term',
|
||||
@@ -141,7 +129,7 @@ export default function Templates({
|
||||
}
|
||||
|
||||
return messages[activeTab as keyof typeof messages] || messages.gallery
|
||||
}, [searchQuery, activeTab])
|
||||
}, [debouncedSearchQuery, activeTab])
|
||||
|
||||
return (
|
||||
<div className='flex h-[100vh] flex-col'>
|
||||
@@ -209,15 +197,12 @@ export default function Templates({
|
||||
key={template.id}
|
||||
id={template.id}
|
||||
title={template.name}
|
||||
description={template.details?.tagline || ''}
|
||||
author={template.creator?.name || 'Unknown'}
|
||||
authorImageUrl={template.creator?.profileImageUrl || null}
|
||||
usageCount={template.views.toString()}
|
||||
stars={template.stars}
|
||||
state={template.state}
|
||||
isStarred={template.isStarred}
|
||||
onStarChange={handleStarChange}
|
||||
isAuthenticated={!!currentUserId}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -23,6 +23,7 @@ import '@/components/emcn/components/code/code.css'
|
||||
interface LogSidebarProps {
|
||||
log: WorkflowLog | null
|
||||
isOpen: boolean
|
||||
isLoadingDetails?: boolean
|
||||
onClose: () => void
|
||||
onNavigateNext?: () => void
|
||||
onNavigatePrev?: () => void
|
||||
@@ -192,6 +193,7 @@ const BlockContentDisplay = ({
|
||||
export function Sidebar({
|
||||
log,
|
||||
isOpen,
|
||||
isLoadingDetails = false,
|
||||
onClose,
|
||||
onNavigateNext,
|
||||
onNavigatePrev,
|
||||
@@ -219,15 +221,6 @@ export function Sidebar({
|
||||
}
|
||||
}, [log?.id])
|
||||
|
||||
const isLoadingDetails = useMemo(() => {
|
||||
if (!log) return false
|
||||
// Only show while we expect details to arrive (has executionId)
|
||||
if (!log.executionId) return false
|
||||
const hasEnhanced = !!log.executionData?.enhanced
|
||||
const hasAnyDetails = hasEnhanced || !!log.cost || Array.isArray(log.executionData?.traceSpans)
|
||||
return !hasAnyDetails
|
||||
}, [log])
|
||||
|
||||
const formattedContent = useMemo(() => {
|
||||
if (!log) return null
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useParams, useRouter, useSearchParams } from 'next/navigation'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { soehne } from '@/app/fonts/soehne/soehne'
|
||||
import Controls from '@/app/workspace/[workspaceId]/logs/components/dashboard/controls'
|
||||
import KPIs from '@/app/workspace/[workspaceId]/logs/components/dashboard/kpis'
|
||||
@@ -11,12 +10,15 @@ import WorkflowDetails from '@/app/workspace/[workspaceId]/logs/components/dashb
|
||||
import WorkflowsList from '@/app/workspace/[workspaceId]/logs/components/dashboard/workflows-list'
|
||||
import Timeline from '@/app/workspace/[workspaceId]/logs/components/filters/components/timeline'
|
||||
import { mapToExecutionLog, mapToExecutionLogAlt } from '@/app/workspace/[workspaceId]/logs/utils'
|
||||
import {
|
||||
useExecutionsMetrics,
|
||||
useGlobalDashboardLogs,
|
||||
useWorkflowDashboardLogs,
|
||||
} from '@/hooks/queries/logs'
|
||||
import { formatCost } from '@/providers/utils'
|
||||
import { useFilterStore } from '@/stores/logs/filters/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
const logger = createLogger('Dashboard')
|
||||
|
||||
type TimeFilter = '30m' | '1h' | '6h' | '12h' | '24h' | '3d' | '7d' | '14d' | '30d'
|
||||
|
||||
interface WorkflowExecution {
|
||||
@@ -59,15 +61,6 @@ interface ExecutionLog {
|
||||
workflowColor?: string
|
||||
}
|
||||
|
||||
interface WorkflowDetailsDataLocal {
|
||||
errorRates: { timestamp: string; value: number }[]
|
||||
durations: { timestamp: string; value: number }[]
|
||||
executionCounts: { timestamp: string; value: number }[]
|
||||
logs: ExecutionLog[]
|
||||
allLogs: ExecutionLog[]
|
||||
__meta?: { offset: number; hasMore: boolean }
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
@@ -99,23 +92,7 @@ export default function Dashboard() {
|
||||
}
|
||||
}
|
||||
const [endTime, setEndTime] = useState<Date>(new Date())
|
||||
const [executions, setExecutions] = useState<WorkflowExecution[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [isRefetching, setIsRefetching] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [expandedWorkflowId, setExpandedWorkflowId] = useState<string | null>(null)
|
||||
const [workflowDetails, setWorkflowDetails] = useState<Record<string, WorkflowDetailsDataLocal>>(
|
||||
{}
|
||||
)
|
||||
const [globalDetails, setGlobalDetails] = useState<WorkflowDetailsDataLocal | null>(null)
|
||||
const [globalLogsMeta, setGlobalLogsMeta] = useState<{ offset: number; hasMore: boolean }>({
|
||||
offset: 0,
|
||||
hasMore: true,
|
||||
})
|
||||
const [globalLoadingMore, setGlobalLoadingMore] = useState(false)
|
||||
const [aggregateSegments, setAggregateSegments] = useState<
|
||||
{ timestamp: string; totalExecutions: number; successfulExecutions: number }[]
|
||||
>([])
|
||||
const [selectedSegments, setSelectedSegments] = useState<Record<string, number[]>>({})
|
||||
const [lastAnchorIndices, setLastAnchorIndices] = useState<Record<string, number>>({})
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
@@ -135,6 +112,134 @@ export default function Dashboard() {
|
||||
|
||||
const timeFilter = getTimeFilterFromRange(sidebarTimeRange)
|
||||
|
||||
const getStartTime = useCallback(() => {
|
||||
const start = new Date(endTime)
|
||||
|
||||
switch (timeFilter) {
|
||||
case '30m':
|
||||
start.setMinutes(endTime.getMinutes() - 30)
|
||||
break
|
||||
case '1h':
|
||||
start.setHours(endTime.getHours() - 1)
|
||||
break
|
||||
case '6h':
|
||||
start.setHours(endTime.getHours() - 6)
|
||||
break
|
||||
case '12h':
|
||||
start.setHours(endTime.getHours() - 12)
|
||||
break
|
||||
case '24h':
|
||||
start.setHours(endTime.getHours() - 24)
|
||||
break
|
||||
case '3d':
|
||||
start.setDate(endTime.getDate() - 3)
|
||||
break
|
||||
case '7d':
|
||||
start.setDate(endTime.getDate() - 7)
|
||||
break
|
||||
case '14d':
|
||||
start.setDate(endTime.getDate() - 14)
|
||||
break
|
||||
case '30d':
|
||||
start.setDate(endTime.getDate() - 30)
|
||||
break
|
||||
default:
|
||||
start.setHours(endTime.getHours() - 24)
|
||||
}
|
||||
|
||||
return start
|
||||
}, [endTime, timeFilter])
|
||||
|
||||
const metricsFilters = useMemo(
|
||||
() => ({
|
||||
workspaceId,
|
||||
segments: segmentCount || DEFAULT_SEGMENTS,
|
||||
startTime: getStartTime().toISOString(),
|
||||
endTime: endTime.toISOString(),
|
||||
workflowIds: workflowIds.length > 0 ? workflowIds : undefined,
|
||||
folderIds: folderIds.length > 0 ? folderIds : undefined,
|
||||
triggers: triggers.length > 0 ? triggers : undefined,
|
||||
}),
|
||||
[workspaceId, segmentCount, getStartTime, endTime, workflowIds, folderIds, triggers]
|
||||
)
|
||||
|
||||
const logsFilters = useMemo(
|
||||
() => ({
|
||||
workspaceId,
|
||||
startDate: getStartTime().toISOString(),
|
||||
endDate: endTime.toISOString(),
|
||||
workflowIds: workflowIds.length > 0 ? workflowIds : undefined,
|
||||
folderIds: folderIds.length > 0 ? folderIds : undefined,
|
||||
triggers: triggers.length > 0 ? triggers : undefined,
|
||||
limit: 50,
|
||||
}),
|
||||
[workspaceId, getStartTime, endTime, workflowIds, folderIds, triggers]
|
||||
)
|
||||
|
||||
const metricsQuery = useExecutionsMetrics(metricsFilters, {
|
||||
enabled: Boolean(workspaceId),
|
||||
})
|
||||
|
||||
const globalLogsQuery = useGlobalDashboardLogs(logsFilters, {
|
||||
enabled: Boolean(workspaceId),
|
||||
})
|
||||
|
||||
const workflowLogsQuery = useWorkflowDashboardLogs(expandedWorkflowId ?? undefined, logsFilters, {
|
||||
enabled: Boolean(workspaceId) && Boolean(expandedWorkflowId),
|
||||
})
|
||||
|
||||
const executions = metricsQuery.data?.workflows ?? []
|
||||
const aggregateSegments = metricsQuery.data?.aggregateSegments ?? []
|
||||
const loading = metricsQuery.isLoading
|
||||
const isRefetching = metricsQuery.isFetching && !metricsQuery.isLoading
|
||||
const error = metricsQuery.error?.message ?? null
|
||||
|
||||
const globalLogs = useMemo(() => {
|
||||
if (!globalLogsQuery.data?.pages) return []
|
||||
return globalLogsQuery.data.pages.flatMap((page) => page.logs).map(mapToExecutionLog)
|
||||
}, [globalLogsQuery.data?.pages])
|
||||
|
||||
const workflowLogs = useMemo(() => {
|
||||
if (!workflowLogsQuery.data?.pages) return []
|
||||
return workflowLogsQuery.data.pages.flatMap((page) => page.logs).map(mapToExecutionLogAlt)
|
||||
}, [workflowLogsQuery.data?.pages])
|
||||
|
||||
const globalDetails = useMemo(() => {
|
||||
if (!aggregateSegments.length) return null
|
||||
|
||||
const errorRates = aggregateSegments.map((s) => ({
|
||||
timestamp: s.timestamp,
|
||||
value: s.totalExecutions > 0 ? (1 - s.successfulExecutions / s.totalExecutions) * 100 : 0,
|
||||
}))
|
||||
|
||||
const executionCounts = aggregateSegments.map((s) => ({
|
||||
timestamp: s.timestamp,
|
||||
value: s.totalExecutions,
|
||||
}))
|
||||
|
||||
return {
|
||||
errorRates,
|
||||
durations: [],
|
||||
executionCounts,
|
||||
logs: globalLogs,
|
||||
allLogs: globalLogs,
|
||||
}
|
||||
}, [aggregateSegments, globalLogs])
|
||||
|
||||
const workflowDetails = useMemo(() => {
|
||||
if (!expandedWorkflowId || !workflowLogs.length) return {}
|
||||
|
||||
return {
|
||||
[expandedWorkflowId]: {
|
||||
errorRates: [],
|
||||
durations: [],
|
||||
executionCounts: [],
|
||||
logs: workflowLogs,
|
||||
allLogs: workflowLogs,
|
||||
},
|
||||
}
|
||||
}, [expandedWorkflowId, workflowLogs])
|
||||
|
||||
useEffect(() => {
|
||||
const urlView = searchParams.get('view')
|
||||
if (urlView === 'dashboard' || urlView === 'logs') {
|
||||
@@ -190,362 +295,24 @@ export default function Dashboard() {
|
||||
}
|
||||
}, [executions])
|
||||
|
||||
const getStartTime = useCallback(() => {
|
||||
const start = new Date(endTime)
|
||||
|
||||
switch (timeFilter) {
|
||||
case '30m':
|
||||
start.setMinutes(endTime.getMinutes() - 30)
|
||||
break
|
||||
case '1h':
|
||||
start.setHours(endTime.getHours() - 1)
|
||||
break
|
||||
case '6h':
|
||||
start.setHours(endTime.getHours() - 6)
|
||||
break
|
||||
case '12h':
|
||||
start.setHours(endTime.getHours() - 12)
|
||||
break
|
||||
case '24h':
|
||||
start.setHours(endTime.getHours() - 24)
|
||||
break
|
||||
case '3d':
|
||||
start.setDate(endTime.getDate() - 3)
|
||||
break
|
||||
case '7d':
|
||||
start.setDate(endTime.getDate() - 7)
|
||||
break
|
||||
case '14d':
|
||||
start.setDate(endTime.getDate() - 14)
|
||||
break
|
||||
case '30d':
|
||||
start.setDate(endTime.getDate() - 30)
|
||||
break
|
||||
default:
|
||||
start.setHours(endTime.getHours() - 24)
|
||||
}
|
||||
|
||||
return start
|
||||
}, [endTime, timeFilter])
|
||||
|
||||
const fetchExecutions = useCallback(
|
||||
async (isInitialLoad = false) => {
|
||||
try {
|
||||
if (isInitialLoad) {
|
||||
setLoading(true)
|
||||
} else {
|
||||
setIsRefetching(true)
|
||||
}
|
||||
setError(null)
|
||||
|
||||
const startTime = getStartTime()
|
||||
const params = new URLSearchParams({
|
||||
segments: String(segmentCount || DEFAULT_SEGMENTS),
|
||||
startTime: startTime.toISOString(),
|
||||
endTime: endTime.toISOString(),
|
||||
})
|
||||
|
||||
if (workflowIds.length > 0) {
|
||||
params.set('workflowIds', workflowIds.join(','))
|
||||
}
|
||||
|
||||
if (folderIds.length > 0) {
|
||||
params.set('folderIds', folderIds.join(','))
|
||||
}
|
||||
|
||||
if (triggers.length > 0) {
|
||||
params.set('triggers', triggers.join(','))
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`/api/workspaces/${workspaceId}/metrics/executions?${params.toString()}`
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch execution history')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const mapped: WorkflowExecution[] = (data.workflows || []).map((wf: any) => {
|
||||
const segments = (wf.segments || []).map((s: any) => {
|
||||
const total = s.totalExecutions || 0
|
||||
const success = s.successfulExecutions || 0
|
||||
const hasExecutions = total > 0
|
||||
const successRate = hasExecutions ? (success / total) * 100 : 100
|
||||
return {
|
||||
timestamp: s.timestamp,
|
||||
hasExecutions,
|
||||
totalExecutions: total,
|
||||
successfulExecutions: success,
|
||||
successRate,
|
||||
avgDurationMs: typeof s.avgDurationMs === 'number' ? s.avgDurationMs : 0,
|
||||
p50Ms: typeof s.p50Ms === 'number' ? s.p50Ms : 0,
|
||||
p90Ms: typeof s.p90Ms === 'number' ? s.p90Ms : 0,
|
||||
p99Ms: typeof s.p99Ms === 'number' ? s.p99Ms : 0,
|
||||
}
|
||||
})
|
||||
const totals = segments.reduce(
|
||||
(acc: { total: number; success: number }, seg: (typeof segments)[number]) => {
|
||||
acc.total += seg.totalExecutions
|
||||
acc.success += seg.successfulExecutions
|
||||
return acc
|
||||
},
|
||||
{ total: 0, success: 0 }
|
||||
)
|
||||
const overallSuccessRate = totals.total > 0 ? (totals.success / totals.total) * 100 : 100
|
||||
return {
|
||||
workflowId: wf.workflowId,
|
||||
workflowName: wf.workflowName,
|
||||
segments,
|
||||
overallSuccessRate,
|
||||
} as WorkflowExecution
|
||||
})
|
||||
const sortedWorkflows = mapped.sort((a, b) => {
|
||||
const errA = a.overallSuccessRate < 100 ? 1 - a.overallSuccessRate / 100 : 0
|
||||
const errB = b.overallSuccessRate < 100 ? 1 - b.overallSuccessRate / 100 : 0
|
||||
return errB - errA
|
||||
})
|
||||
setExecutions(sortedWorkflows)
|
||||
|
||||
const segmentsCount: number = Number(params.get('segments') || DEFAULT_SEGMENTS)
|
||||
const agg: { timestamp: string; totalExecutions: number; successfulExecutions: number }[] =
|
||||
Array.from({ length: segmentsCount }, (_, i) => {
|
||||
const base = startTime.getTime()
|
||||
const ts = new Date(base + Math.floor((i * (endTime.getTime() - base)) / segmentsCount))
|
||||
return {
|
||||
timestamp: ts.toISOString(),
|
||||
totalExecutions: 0,
|
||||
successfulExecutions: 0,
|
||||
}
|
||||
})
|
||||
for (const wf of data.workflows as any[]) {
|
||||
wf.segments.forEach((s: any, i: number) => {
|
||||
const index = Math.min(i, segmentsCount - 1)
|
||||
agg[index].totalExecutions += s.totalExecutions || 0
|
||||
agg[index].successfulExecutions += s.successfulExecutions || 0
|
||||
})
|
||||
}
|
||||
setAggregateSegments(agg)
|
||||
|
||||
const errorRates = agg.map((s) => ({
|
||||
timestamp: s.timestamp,
|
||||
value: s.totalExecutions > 0 ? (1 - s.successfulExecutions / s.totalExecutions) * 100 : 0,
|
||||
}))
|
||||
const executionCounts = agg.map((s) => ({
|
||||
timestamp: s.timestamp,
|
||||
value: s.totalExecutions,
|
||||
}))
|
||||
|
||||
const logsParams = new URLSearchParams({
|
||||
limit: '50',
|
||||
offset: '0',
|
||||
workspaceId,
|
||||
startDate: startTime.toISOString(),
|
||||
endDate: endTime.toISOString(),
|
||||
order: 'desc',
|
||||
details: 'full',
|
||||
})
|
||||
if (workflowIds.length > 0) logsParams.set('workflowIds', workflowIds.join(','))
|
||||
if (folderIds.length > 0) logsParams.set('folderIds', folderIds.join(','))
|
||||
if (triggers.length > 0) logsParams.set('triggers', triggers.join(','))
|
||||
|
||||
const logsResponse = await fetch(`/api/logs?${logsParams.toString()}`)
|
||||
let mappedLogs: ExecutionLog[] = []
|
||||
if (logsResponse.ok) {
|
||||
const logsData = await logsResponse.json()
|
||||
mappedLogs = (logsData.data || []).map(mapToExecutionLog)
|
||||
}
|
||||
|
||||
setGlobalDetails({
|
||||
errorRates,
|
||||
durations: [],
|
||||
executionCounts,
|
||||
logs: mappedLogs,
|
||||
allLogs: mappedLogs,
|
||||
})
|
||||
setGlobalLogsMeta({ offset: mappedLogs.length, hasMore: mappedLogs.length === 50 })
|
||||
} catch (err) {
|
||||
logger.error('Error fetching executions:', err)
|
||||
setError(err instanceof Error ? err.message : 'An error occurred')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setIsRefetching(false)
|
||||
}
|
||||
},
|
||||
[workspaceId, timeFilter, endTime, getStartTime, workflowIds, folderIds, triggers, segmentCount]
|
||||
)
|
||||
|
||||
const fetchWorkflowDetails = useCallback(
|
||||
async (workflowId: string, silent = false) => {
|
||||
try {
|
||||
const startTime = getStartTime()
|
||||
const params = new URLSearchParams({
|
||||
startTime: startTime.toISOString(),
|
||||
endTime: endTime.toISOString(),
|
||||
})
|
||||
|
||||
if (triggers.length > 0) {
|
||||
params.set('triggers', triggers.join(','))
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`/api/logs?${new URLSearchParams({
|
||||
limit: '50',
|
||||
offset: '0',
|
||||
workspaceId,
|
||||
startDate: startTime.toISOString(),
|
||||
endDate: endTime.toISOString(),
|
||||
order: 'desc',
|
||||
details: 'full',
|
||||
workflowIds: workflowId,
|
||||
...(triggers.length > 0 ? { triggers: triggers.join(',') } : {}),
|
||||
}).toString()}`
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch workflow details')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const mappedLogs: ExecutionLog[] = (data.data || []).map(mapToExecutionLogAlt)
|
||||
|
||||
setWorkflowDetails((prev) => ({
|
||||
...prev,
|
||||
[workflowId]: {
|
||||
errorRates: [],
|
||||
durations: [],
|
||||
executionCounts: [],
|
||||
logs: mappedLogs,
|
||||
allLogs: mappedLogs,
|
||||
__meta: { offset: mappedLogs.length, hasMore: (data.data || []).length === 50 },
|
||||
},
|
||||
}))
|
||||
} catch (err) {
|
||||
logger.error('Error fetching workflow details:', err)
|
||||
}
|
||||
},
|
||||
[workspaceId, endTime, getStartTime, triggers]
|
||||
)
|
||||
|
||||
// Infinite scroll for details logs
|
||||
const loadMoreLogs = useCallback(
|
||||
async (workflowId: string) => {
|
||||
const details = (workflowDetails as any)[workflowId]
|
||||
if (!details) return
|
||||
if (details.__loading) return
|
||||
if (!details.__meta?.hasMore) return
|
||||
try {
|
||||
// mark loading to prevent duplicate fetches
|
||||
setWorkflowDetails((prev) => ({
|
||||
...prev,
|
||||
[workflowId]: { ...(prev as any)[workflowId], __loading: true },
|
||||
}))
|
||||
const startTime = getStartTime()
|
||||
const offset = details.__meta.offset || 0
|
||||
const qp = new URLSearchParams({
|
||||
limit: '50',
|
||||
offset: String(offset),
|
||||
workspaceId,
|
||||
startDate: startTime.toISOString(),
|
||||
endDate: endTime.toISOString(),
|
||||
order: 'desc',
|
||||
details: 'full',
|
||||
workflowIds: workflowId,
|
||||
})
|
||||
if (triggers.length > 0) qp.set('triggers', triggers.join(','))
|
||||
const res = await fetch(`/api/logs?${qp.toString()}`)
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
const more: ExecutionLog[] = (data.data || []).map(mapToExecutionLogAlt)
|
||||
|
||||
setWorkflowDetails((prev) => {
|
||||
const cur = prev[workflowId]
|
||||
const seen = new Set<string>()
|
||||
const dedup = [...(cur?.allLogs || []), ...more].filter((x) => {
|
||||
const id = x.id
|
||||
if (seen.has(id)) return false
|
||||
seen.add(id)
|
||||
return true
|
||||
})
|
||||
return {
|
||||
...prev,
|
||||
[workflowId]: {
|
||||
...cur,
|
||||
logs: dedup,
|
||||
allLogs: dedup,
|
||||
__meta: {
|
||||
offset: (cur?.__meta?.offset || 0) + more.length,
|
||||
hasMore: more.length === 50,
|
||||
},
|
||||
__loading: false,
|
||||
},
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
setWorkflowDetails((prev) => ({
|
||||
...prev,
|
||||
[workflowId]: { ...(prev as any)[workflowId], __loading: false },
|
||||
}))
|
||||
(workflowId: string) => {
|
||||
if (
|
||||
workflowId === expandedWorkflowId &&
|
||||
workflowLogsQuery.hasNextPage &&
|
||||
!workflowLogsQuery.isFetchingNextPage
|
||||
) {
|
||||
workflowLogsQuery.fetchNextPage()
|
||||
}
|
||||
},
|
||||
[workspaceId, endTime, getStartTime, triggers, workflowDetails]
|
||||
[expandedWorkflowId, workflowLogsQuery]
|
||||
)
|
||||
|
||||
const loadMoreGlobalLogs = useCallback(async () => {
|
||||
if (!globalDetails || !globalLogsMeta.hasMore) return
|
||||
if (globalLoadingMore) return
|
||||
try {
|
||||
setGlobalLoadingMore(true)
|
||||
const startTime = getStartTime()
|
||||
const qp = new URLSearchParams({
|
||||
limit: '50',
|
||||
offset: String(globalLogsMeta.offset || 0),
|
||||
workspaceId,
|
||||
startDate: startTime.toISOString(),
|
||||
endDate: endTime.toISOString(),
|
||||
order: 'desc',
|
||||
details: 'full',
|
||||
})
|
||||
if (workflowIds.length > 0) qp.set('workflowIds', workflowIds.join(','))
|
||||
if (folderIds.length > 0) qp.set('folderIds', folderIds.join(','))
|
||||
if (triggers.length > 0) qp.set('triggers', triggers.join(','))
|
||||
|
||||
const res = await fetch(`/api/logs?${qp.toString()}`)
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
const more: ExecutionLog[] = (data.data || []).map(mapToExecutionLog)
|
||||
|
||||
setGlobalDetails((prev) => {
|
||||
if (!prev) return prev
|
||||
const seen = new Set<string>()
|
||||
const dedup = [...prev.allLogs, ...more].filter((x) => {
|
||||
const id = x.id
|
||||
if (seen.has(id)) return false
|
||||
seen.add(id)
|
||||
return true
|
||||
})
|
||||
return { ...prev, logs: dedup, allLogs: dedup }
|
||||
})
|
||||
setGlobalLogsMeta((m) => ({
|
||||
offset: (m.offset || 0) + more.length,
|
||||
hasMore: more.length === 50,
|
||||
}))
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setGlobalLoadingMore(false)
|
||||
const loadMoreGlobalLogs = useCallback(() => {
|
||||
if (globalLogsQuery.hasNextPage && !globalLogsQuery.isFetchingNextPage) {
|
||||
globalLogsQuery.fetchNextPage()
|
||||
}
|
||||
}, [
|
||||
globalDetails,
|
||||
globalLogsMeta,
|
||||
globalLoadingMore,
|
||||
workspaceId,
|
||||
endTime,
|
||||
getStartTime,
|
||||
workflowIds,
|
||||
folderIds,
|
||||
triggers,
|
||||
])
|
||||
}, [globalLogsQuery])
|
||||
|
||||
const toggleWorkflow = useCallback(
|
||||
(workflowId: string) => {
|
||||
@@ -553,12 +320,9 @@ export default function Dashboard() {
|
||||
setExpandedWorkflowId(null)
|
||||
} else {
|
||||
setExpandedWorkflowId(workflowId)
|
||||
if (!workflowDetails[workflowId]) {
|
||||
fetchWorkflowDetails(workflowId)
|
||||
}
|
||||
}
|
||||
},
|
||||
[expandedWorkflowId, workflowDetails, fetchWorkflowDetails]
|
||||
[expandedWorkflowId]
|
||||
)
|
||||
|
||||
const handleSegmentClick = useCallback(
|
||||
@@ -568,13 +332,7 @@ export default function Dashboard() {
|
||||
_timestamp: string,
|
||||
mode: 'single' | 'toggle' | 'range'
|
||||
) => {
|
||||
// Fetch workflow details if not already loaded
|
||||
if (!workflowDetails[workflowId]) {
|
||||
fetchWorkflowDetails(workflowId)
|
||||
}
|
||||
|
||||
if (mode === 'toggle') {
|
||||
// Toggle mode: Add/remove segment from selection, allowing cross-workflow selection
|
||||
setSelectedSegments((prev) => {
|
||||
const currentSegments = prev[workflowId] || []
|
||||
const exists = currentSegments.includes(segmentIndex)
|
||||
@@ -584,7 +342,6 @@ export default function Dashboard() {
|
||||
|
||||
if (nextSegments.length === 0) {
|
||||
const { [workflowId]: _, ...rest } = prev
|
||||
// If this was the only workflow with selections, clear expanded
|
||||
if (Object.keys(rest).length === 0) {
|
||||
setExpandedWorkflowId(null)
|
||||
}
|
||||
@@ -593,7 +350,6 @@ export default function Dashboard() {
|
||||
|
||||
const newState = { ...prev, [workflowId]: nextSegments }
|
||||
|
||||
// Set to multi-workflow mode if multiple workflows have selections
|
||||
const selectedWorkflowIds = Object.keys(newState)
|
||||
if (selectedWorkflowIds.length > 1) {
|
||||
setExpandedWorkflowId('__multi__')
|
||||
@@ -606,27 +362,23 @@ export default function Dashboard() {
|
||||
|
||||
setLastAnchorIndices((prev) => ({ ...prev, [workflowId]: segmentIndex }))
|
||||
} else if (mode === 'single') {
|
||||
// Single mode: Select this segment, or deselect if already selected
|
||||
setSelectedSegments((prev) => {
|
||||
const currentSegments = prev[workflowId] || []
|
||||
const isOnlySelectedSegment =
|
||||
currentSegments.length === 1 && currentSegments[0] === segmentIndex
|
||||
const isOnlyWorkflowSelected = Object.keys(prev).length === 1 && prev[workflowId]
|
||||
|
||||
// If this is the only selected segment in the only selected workflow, deselect it
|
||||
if (isOnlySelectedSegment && isOnlyWorkflowSelected) {
|
||||
setExpandedWorkflowId(null)
|
||||
setLastAnchorIndices({})
|
||||
return {}
|
||||
}
|
||||
|
||||
// Otherwise, select only this segment
|
||||
setExpandedWorkflowId(workflowId)
|
||||
setLastAnchorIndices({ [workflowId]: segmentIndex })
|
||||
return { [workflowId]: [segmentIndex] }
|
||||
})
|
||||
} else if (mode === 'range') {
|
||||
// Range mode: Expand selection within the current workflow
|
||||
if (expandedWorkflowId === workflowId) {
|
||||
setSelectedSegments((prev) => {
|
||||
const currentSegments = prev[workflowId] || []
|
||||
@@ -638,31 +390,15 @@ export default function Dashboard() {
|
||||
return { ...prev, [workflowId]: Array.from(union).sort((a, b) => a - b) }
|
||||
})
|
||||
} else {
|
||||
// If clicking range on a different workflow, treat as single click
|
||||
setExpandedWorkflowId(workflowId)
|
||||
setSelectedSegments({ [workflowId]: [segmentIndex] })
|
||||
setLastAnchorIndices({ [workflowId]: segmentIndex })
|
||||
}
|
||||
}
|
||||
},
|
||||
[expandedWorkflowId, workflowDetails, fetchWorkflowDetails, lastAnchorIndices]
|
||||
[expandedWorkflowId, workflowDetails, lastAnchorIndices]
|
||||
)
|
||||
|
||||
const isInitialMount = useRef(true)
|
||||
useEffect(() => {
|
||||
const isInitial = isInitialMount.current
|
||||
if (isInitial) {
|
||||
isInitialMount.current = false
|
||||
}
|
||||
fetchExecutions(isInitial)
|
||||
}, [workspaceId, timeFilter, endTime, workflowIds, folderIds, triggers, segmentCount])
|
||||
|
||||
useEffect(() => {
|
||||
if (expandedWorkflowId) {
|
||||
fetchWorkflowDetails(expandedWorkflowId)
|
||||
}
|
||||
}, [expandedWorkflowId, timeFilter, endTime, workflowIds, folderIds, fetchWorkflowDetails])
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedSegments({})
|
||||
setLastAnchorIndices({})
|
||||
@@ -692,68 +428,15 @@ export default function Dashboard() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const getShiftLabel = () => {
|
||||
switch (sidebarTimeRange) {
|
||||
case 'Past 30 minutes':
|
||||
return '30 minutes'
|
||||
case 'Past hour':
|
||||
return 'hour'
|
||||
case 'Past 12 hours':
|
||||
return '12 hours'
|
||||
case 'Past 24 hours':
|
||||
return '24 hours'
|
||||
default:
|
||||
return 'period'
|
||||
}
|
||||
}
|
||||
|
||||
const getDateRange = () => {
|
||||
const start = getStartTime()
|
||||
return `${start.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })} - ${endTime.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', year: 'numeric' })}`
|
||||
}
|
||||
|
||||
const shiftTimeWindow = (direction: 'back' | 'forward') => {
|
||||
let shift: number
|
||||
switch (timeFilter) {
|
||||
case '30m':
|
||||
shift = 30 * 60 * 1000
|
||||
break
|
||||
case '1h':
|
||||
shift = 60 * 60 * 1000
|
||||
break
|
||||
case '6h':
|
||||
shift = 6 * 60 * 60 * 1000
|
||||
break
|
||||
case '12h':
|
||||
shift = 12 * 60 * 60 * 1000
|
||||
break
|
||||
case '24h':
|
||||
shift = 24 * 60 * 60 * 1000
|
||||
break
|
||||
case '3d':
|
||||
shift = 3 * 24 * 60 * 60 * 1000
|
||||
break
|
||||
case '7d':
|
||||
shift = 7 * 24 * 60 * 60 * 1000
|
||||
break
|
||||
case '14d':
|
||||
shift = 14 * 24 * 60 * 60 * 1000
|
||||
break
|
||||
case '30d':
|
||||
shift = 30 * 24 * 60 * 60 * 1000
|
||||
break
|
||||
default:
|
||||
shift = 24 * 60 * 60 * 1000
|
||||
}
|
||||
|
||||
setEndTime((prev) => new Date(prev.getTime() + (direction === 'forward' ? shift : -shift)))
|
||||
}
|
||||
|
||||
const resetToNow = () => {
|
||||
setEndTime(new Date())
|
||||
}
|
||||
|
||||
const isLive = endTime.getTime() > Date.now() - 60000 // Within last minute
|
||||
const [live, setLive] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -768,8 +451,6 @@ export default function Dashboard() {
|
||||
}
|
||||
}, [live])
|
||||
|
||||
// Infinite scroll is now handled inside WorkflowDetails
|
||||
|
||||
return (
|
||||
<div className={`flex h-full min-w-0 flex-col pl-64 ${soehne.className}`}>
|
||||
<div className='flex min-w-0 flex-1 overflow-hidden'>
|
||||
@@ -873,25 +554,21 @@ export default function Dashboard() {
|
||||
{/* Details section in its own scroll area */}
|
||||
<div className='min-h-0 flex-1 overflow-auto'>
|
||||
{(() => {
|
||||
// Handle multi-workflow selection view
|
||||
if (expandedWorkflowId === '__multi__') {
|
||||
const selectedWorkflowIds = Object.keys(selectedSegments)
|
||||
const totalMs = endTime.getTime() - getStartTime().getTime()
|
||||
const segMs = totalMs / Math.max(1, segmentCount)
|
||||
|
||||
// Collect all unique segment indices across all workflows
|
||||
const allSegmentIndices = new Set<number>()
|
||||
for (const indices of Object.values(selectedSegments)) {
|
||||
indices.forEach((idx) => allSegmentIndices.add(idx))
|
||||
}
|
||||
const sortedIndices = Array.from(allSegmentIndices).sort((a, b) => a - b)
|
||||
|
||||
// Aggregate logs from all selected workflows/segments
|
||||
const allLogs: any[] = []
|
||||
let totalExecutions = 0
|
||||
let totalSuccess = 0
|
||||
|
||||
// Build aggregated chart series
|
||||
const aggregatedSegments: Array<{
|
||||
timestamp: string
|
||||
totalExecutions: number
|
||||
@@ -900,9 +577,7 @@ export default function Dashboard() {
|
||||
durationCount: number
|
||||
}> = []
|
||||
|
||||
// Initialize aggregated segments for each unique index
|
||||
for (const idx of sortedIndices) {
|
||||
// Get the timestamp from the first workflow that has this index
|
||||
let timestamp = ''
|
||||
for (const wfId of selectedWorkflowIds) {
|
||||
const wf = executions.find((w) => w.workflowId === wfId)
|
||||
@@ -921,7 +596,6 @@ export default function Dashboard() {
|
||||
})
|
||||
}
|
||||
|
||||
// Aggregate data from all workflows
|
||||
for (const wfId of selectedWorkflowIds) {
|
||||
const wf = executions.find((w) => w.workflowId === wfId)
|
||||
const details = workflowDetails[wfId]
|
||||
@@ -929,7 +603,6 @@ export default function Dashboard() {
|
||||
|
||||
if (!wf || !details || indices.length === 0) continue
|
||||
|
||||
// Calculate time windows for this workflow's selected segments
|
||||
const windows = indices
|
||||
.map((idx) => wf.segments[idx])
|
||||
.filter(Boolean)
|
||||
@@ -944,7 +617,6 @@ export default function Dashboard() {
|
||||
const inAnyWindow = (t: number) =>
|
||||
windows.some((w) => t >= w.start && t < w.end)
|
||||
|
||||
// Filter logs for this workflow's selected segments
|
||||
const workflowLogs = details.allLogs
|
||||
.filter((log) => inAnyWindow(new Date(log.startedAt).getTime()))
|
||||
.map((log) => ({
|
||||
@@ -956,7 +628,6 @@ export default function Dashboard() {
|
||||
|
||||
allLogs.push(...workflowLogs)
|
||||
|
||||
// Aggregate segment metrics
|
||||
indices.forEach((idx) => {
|
||||
const segment = wf.segments[idx]
|
||||
if (!segment) return
|
||||
@@ -974,7 +645,6 @@ export default function Dashboard() {
|
||||
})
|
||||
}
|
||||
|
||||
// Build chart series
|
||||
const errorRates = aggregatedSegments.map((seg) => ({
|
||||
timestamp: seg.timestamp,
|
||||
value:
|
||||
@@ -993,7 +663,6 @@ export default function Dashboard() {
|
||||
value: seg.durationCount > 0 ? seg.avgDurationMs / seg.durationCount : 0,
|
||||
}))
|
||||
|
||||
// Sort logs by time (most recent first)
|
||||
allLogs.sort(
|
||||
(a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()
|
||||
)
|
||||
@@ -1002,13 +671,11 @@ export default function Dashboard() {
|
||||
const totalRate =
|
||||
totalExecutions > 0 ? (totalSuccess / totalExecutions) * 100 : 100
|
||||
|
||||
// Calculate overall time range across all selected workflows
|
||||
let multiWorkflowTimeRange: { start: Date; end: Date } | null = null
|
||||
if (sortedIndices.length > 0) {
|
||||
const firstIdx = sortedIndices[0]
|
||||
const lastIdx = sortedIndices[sortedIndices.length - 1]
|
||||
|
||||
// Find earliest start time
|
||||
let earliestStart: Date | null = null
|
||||
for (const wfId of selectedWorkflowIds) {
|
||||
const wf = executions.find((w) => w.workflowId === wfId)
|
||||
@@ -1021,7 +688,6 @@ export default function Dashboard() {
|
||||
}
|
||||
}
|
||||
|
||||
// Find latest end time
|
||||
let latestEnd: Date | null = null
|
||||
for (const wfId of selectedWorkflowIds) {
|
||||
const wf = executions.find((w) => w.workflowId === wfId)
|
||||
@@ -1042,7 +708,6 @@ export default function Dashboard() {
|
||||
}
|
||||
}
|
||||
|
||||
// Get workflow names
|
||||
const workflowNames = selectedWorkflowIds
|
||||
.map((id) => executions.find((w) => w.workflowId === id)?.workflowName)
|
||||
.filter(Boolean) as string[]
|
||||
@@ -1179,33 +844,25 @@ export default function Dashboard() {
|
||||
...log,
|
||||
workflowName: (log as any).workflowName || wf.workflowName,
|
||||
}))
|
||||
|
||||
// Build series from selected segments indices
|
||||
const idxSet = new Set(workflowSelectedIndices)
|
||||
const selectedSegs = wf.segments.filter((_, i) => idxSet.has(i))
|
||||
;(details as any).__filtered = buildSeriesFromSegments(selectedSegs as any)
|
||||
} else if (details) {
|
||||
// Clear filtered data when no segments are selected
|
||||
;(details as any).__filtered = undefined
|
||||
}
|
||||
|
||||
// Compute series data based on selected segments or all segments
|
||||
const segmentsToUse =
|
||||
workflowSelectedIndices.length > 0
|
||||
? wf.segments.filter((_, i) => workflowSelectedIndices.includes(i))
|
||||
: wf.segments
|
||||
const series = buildSeriesFromSegments(segmentsToUse as any)
|
||||
|
||||
const detailsWithFilteredLogs = details
|
||||
? {
|
||||
...details,
|
||||
logs: logsToDisplay,
|
||||
...(() => {
|
||||
const series =
|
||||
(details as any).__filtered ||
|
||||
buildSeriesFromSegments(wf.segments as any)
|
||||
return {
|
||||
errorRates: series.errorRates,
|
||||
durations: series.durations,
|
||||
executionCounts: series.executionCounts,
|
||||
durationP50: series.durationP50,
|
||||
durationP90: series.durationP90,
|
||||
durationP99: series.durationP99,
|
||||
}
|
||||
})(),
|
||||
errorRates: series.errorRates,
|
||||
durations: series.durations,
|
||||
executionCounts: series.executionCounts,
|
||||
durationP50: series.durationP50,
|
||||
durationP90: series.durationP90,
|
||||
durationP99: series.durationP99,
|
||||
}
|
||||
: undefined
|
||||
|
||||
@@ -1261,8 +918,8 @@ export default function Dashboard() {
|
||||
}}
|
||||
formatCost={formatCost}
|
||||
onLoadMore={() => loadMoreLogs(expandedWorkflowId)}
|
||||
hasMore={(workflowDetails as any)[expandedWorkflowId]?.__meta?.hasMore}
|
||||
isLoadingMore={(workflowDetails as any)[expandedWorkflowId]?.__loading}
|
||||
hasMore={workflowLogsQuery.hasNextPage ?? false}
|
||||
isLoadingMore={workflowLogsQuery.isFetchingNextPage}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1297,8 +954,8 @@ export default function Dashboard() {
|
||||
}}
|
||||
formatCost={formatCost}
|
||||
onLoadMore={loadMoreGlobalLogs}
|
||||
hasMore={globalLogsMeta.hasMore}
|
||||
isLoadingMore={globalLoadingMore}
|
||||
hasMore={globalLogsQuery.hasNextPage ?? false}
|
||||
isLoadingMore={globalLogsQuery.isFetchingNextPage}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { AlertCircle, ArrowUpRight, Info, Loader2 } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { parseQuery, queryToApiParams } from '@/lib/logs/query-parser'
|
||||
import { cn } from '@/lib/utils'
|
||||
import Controls from '@/app/workspace/[workspaceId]/logs/components/dashboard/controls'
|
||||
@@ -13,12 +12,12 @@ import { Sidebar } from '@/app/workspace/[workspaceId]/logs/components/sidebar/s
|
||||
import Dashboard from '@/app/workspace/[workspaceId]/logs/dashboard'
|
||||
import { formatDate } from '@/app/workspace/[workspaceId]/logs/utils'
|
||||
import { useFolders } from '@/hooks/queries/folders'
|
||||
import { useLogDetail, useLogsList } from '@/hooks/queries/logs'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
import { useFolderStore } from '@/stores/folders/store'
|
||||
import { useFilterStore } from '@/stores/logs/filters/store'
|
||||
import type { LogsResponse, WorkflowLog } from '@/stores/logs/filters/types'
|
||||
import type { WorkflowLog } from '@/stores/logs/filters/types'
|
||||
|
||||
const logger = createLogger('Logs')
|
||||
const LOGS_PER_PAGE = 50
|
||||
|
||||
/**
|
||||
@@ -63,19 +62,7 @@ export default function Logs() {
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
const {
|
||||
logs,
|
||||
loading,
|
||||
error,
|
||||
setLogs,
|
||||
setLoading,
|
||||
setError,
|
||||
setWorkspaceId,
|
||||
page,
|
||||
setPage,
|
||||
hasMore,
|
||||
setHasMore,
|
||||
isFetchingMore,
|
||||
setIsFetchingMore,
|
||||
initializeFromURL,
|
||||
timeRange,
|
||||
level,
|
||||
@@ -95,10 +82,6 @@ export default function Logs() {
|
||||
const [selectedLog, setSelectedLog] = useState<WorkflowLog | null>(null)
|
||||
const [selectedLogIndex, setSelectedLogIndex] = useState<number>(-1)
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
|
||||
const [isDetailsLoading, setIsDetailsLoading] = useState(false)
|
||||
const detailsCacheRef = useRef<Map<string, any>>(new Map())
|
||||
const detailsAbortRef = useRef<AbortController | null>(null)
|
||||
const currentDetailsIdRef = useRef<string | null>(null)
|
||||
const selectedRowRef = useRef<HTMLTableRowElement | null>(null)
|
||||
const loaderRef = useRef<HTMLDivElement>(null)
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
@@ -107,16 +90,37 @@ export default function Logs() {
|
||||
const [searchQuery, setSearchQuery] = useState(storeSearchQuery)
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 300)
|
||||
|
||||
const [availableWorkflows, setAvailableWorkflows] = useState<string[]>([])
|
||||
const [availableFolders, setAvailableFolders] = useState<string[]>([])
|
||||
const [, setAvailableWorkflows] = useState<string[]>([])
|
||||
const [, setAvailableFolders] = useState<string[]>([])
|
||||
|
||||
// Live and refresh state
|
||||
const [isLive, setIsLive] = useState(false)
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const liveIntervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const isSearchOpenRef = useRef<boolean>(false)
|
||||
|
||||
// Sync local search query with store search query
|
||||
const logFilters = useMemo(
|
||||
() => ({
|
||||
timeRange,
|
||||
level,
|
||||
workflowIds,
|
||||
folderIds,
|
||||
triggers,
|
||||
searchQuery: debouncedSearchQuery,
|
||||
limit: LOGS_PER_PAGE,
|
||||
}),
|
||||
[timeRange, level, workflowIds, folderIds, triggers, debouncedSearchQuery]
|
||||
)
|
||||
|
||||
const logsQuery = useLogsList(workspaceId, logFilters, {
|
||||
enabled: Boolean(workspaceId) && isInitialized.current,
|
||||
refetchInterval: isLive ? 5000 : false,
|
||||
})
|
||||
|
||||
const logDetailQuery = useLogDetail(selectedLog?.id)
|
||||
|
||||
const logs = useMemo(() => {
|
||||
if (!logsQuery.data?.pages) return []
|
||||
return logsQuery.data.pages.flatMap((page) => page.logs)
|
||||
}, [logsQuery.data?.pages])
|
||||
|
||||
useEffect(() => {
|
||||
setSearchQuery(storeSearchQuery)
|
||||
}, [storeSearchQuery])
|
||||
@@ -182,62 +186,6 @@ export default function Logs() {
|
||||
const index = logs.findIndex((l) => l.id === log.id)
|
||||
setSelectedLogIndex(index)
|
||||
setIsSidebarOpen(true)
|
||||
setIsDetailsLoading(true)
|
||||
|
||||
const currentId = log.id
|
||||
const prevId = index > 0 ? logs[index - 1]?.id : undefined
|
||||
const nextId = index < logs.length - 1 ? logs[index + 1]?.id : undefined
|
||||
|
||||
if (detailsAbortRef.current) {
|
||||
try {
|
||||
detailsAbortRef.current.abort()
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
}
|
||||
const controller = new AbortController()
|
||||
detailsAbortRef.current = controller
|
||||
currentDetailsIdRef.current = currentId
|
||||
|
||||
const idsToFetch: Array<{ id: string; merge: boolean }> = []
|
||||
const cachedCurrent = currentId ? detailsCacheRef.current.get(currentId) : undefined
|
||||
if (currentId && !cachedCurrent) idsToFetch.push({ id: currentId, merge: true })
|
||||
if (prevId && !detailsCacheRef.current.has(prevId))
|
||||
idsToFetch.push({ id: prevId, merge: false })
|
||||
if (nextId && !detailsCacheRef.current.has(nextId))
|
||||
idsToFetch.push({ id: nextId, merge: false })
|
||||
|
||||
if (cachedCurrent) {
|
||||
setSelectedLog((prev) =>
|
||||
prev && prev.id === currentId
|
||||
? ({ ...(prev as any), ...(cachedCurrent as any) } as any)
|
||||
: prev
|
||||
)
|
||||
setIsDetailsLoading(false)
|
||||
}
|
||||
if (idsToFetch.length === 0) return
|
||||
|
||||
Promise.all(
|
||||
idsToFetch.map(async ({ id, merge }) => {
|
||||
try {
|
||||
const res = await fetch(`/api/logs/${id}`, { signal: controller.signal })
|
||||
if (!res.ok) return
|
||||
const body = await res.json()
|
||||
const detailed = body?.data
|
||||
if (detailed) {
|
||||
detailsCacheRef.current.set(id, detailed)
|
||||
if (merge && id === currentId) {
|
||||
setSelectedLog((prev) =>
|
||||
prev && prev.id === id ? ({ ...(prev as any), ...(detailed as any) } as any) : prev
|
||||
)
|
||||
if (currentDetailsIdRef.current === id) setIsDetailsLoading(false)
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e?.name === 'AbortError') return
|
||||
}
|
||||
})
|
||||
).catch(() => {})
|
||||
}
|
||||
|
||||
const handleNavigateNext = useCallback(() => {
|
||||
@@ -246,54 +194,6 @@ export default function Logs() {
|
||||
setSelectedLogIndex(nextIndex)
|
||||
const nextLog = logs[nextIndex]
|
||||
setSelectedLog(nextLog)
|
||||
if (detailsAbortRef.current) {
|
||||
try {
|
||||
detailsAbortRef.current.abort()
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
}
|
||||
const controller = new AbortController()
|
||||
detailsAbortRef.current = controller
|
||||
|
||||
const cached = detailsCacheRef.current.get(nextLog.id)
|
||||
if (cached) {
|
||||
setSelectedLog((prev) =>
|
||||
prev && prev.id === nextLog.id ? ({ ...(prev as any), ...(cached as any) } as any) : prev
|
||||
)
|
||||
} else {
|
||||
const prevId = nextIndex > 0 ? logs[nextIndex - 1]?.id : undefined
|
||||
const afterId = nextIndex < logs.length - 1 ? logs[nextIndex + 1]?.id : undefined
|
||||
const idsToFetch: Array<{ id: string; merge: boolean }> = []
|
||||
if (nextLog.id && !detailsCacheRef.current.has(nextLog.id))
|
||||
idsToFetch.push({ id: nextLog.id, merge: true })
|
||||
if (prevId && !detailsCacheRef.current.has(prevId))
|
||||
idsToFetch.push({ id: prevId, merge: false })
|
||||
if (afterId && !detailsCacheRef.current.has(afterId))
|
||||
idsToFetch.push({ id: afterId, merge: false })
|
||||
Promise.all(
|
||||
idsToFetch.map(async ({ id, merge }) => {
|
||||
try {
|
||||
const res = await fetch(`/api/logs/${id}`, { signal: controller.signal })
|
||||
if (!res.ok) return
|
||||
const body = await res.json()
|
||||
const detailed = body?.data
|
||||
if (detailed) {
|
||||
detailsCacheRef.current.set(id, detailed)
|
||||
if (merge && id === nextLog.id) {
|
||||
setSelectedLog((prev) =>
|
||||
prev && prev.id === id
|
||||
? ({ ...(prev as any), ...(detailed as any) } as any)
|
||||
: prev
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e?.name === 'AbortError') return
|
||||
}
|
||||
})
|
||||
).catch(() => {})
|
||||
}
|
||||
}
|
||||
}, [selectedLogIndex, logs])
|
||||
|
||||
@@ -303,54 +203,6 @@ export default function Logs() {
|
||||
setSelectedLogIndex(prevIndex)
|
||||
const prevLog = logs[prevIndex]
|
||||
setSelectedLog(prevLog)
|
||||
if (detailsAbortRef.current) {
|
||||
try {
|
||||
detailsAbortRef.current.abort()
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
}
|
||||
const controller = new AbortController()
|
||||
detailsAbortRef.current = controller
|
||||
|
||||
const cached = detailsCacheRef.current.get(prevLog.id)
|
||||
if (cached) {
|
||||
setSelectedLog((prev) =>
|
||||
prev && prev.id === prevLog.id ? ({ ...(prev as any), ...(cached as any) } as any) : prev
|
||||
)
|
||||
} else {
|
||||
const beforeId = prevIndex > 0 ? logs[prevIndex - 1]?.id : undefined
|
||||
const afterId = prevIndex < logs.length - 1 ? logs[prevIndex + 1]?.id : undefined
|
||||
const idsToFetch: Array<{ id: string; merge: boolean }> = []
|
||||
if (prevLog.id && !detailsCacheRef.current.has(prevLog.id))
|
||||
idsToFetch.push({ id: prevLog.id, merge: true })
|
||||
if (beforeId && !detailsCacheRef.current.has(beforeId))
|
||||
idsToFetch.push({ id: beforeId, merge: false })
|
||||
if (afterId && !detailsCacheRef.current.has(afterId))
|
||||
idsToFetch.push({ id: afterId, merge: false })
|
||||
Promise.all(
|
||||
idsToFetch.map(async ({ id, merge }) => {
|
||||
try {
|
||||
const res = await fetch(`/api/logs/${id}`, { signal: controller.signal })
|
||||
if (!res.ok) return
|
||||
const body = await res.json()
|
||||
const detailed = body?.data
|
||||
if (detailed) {
|
||||
detailsCacheRef.current.set(id, detailed)
|
||||
if (merge && id === prevLog.id) {
|
||||
setSelectedLog((prev) =>
|
||||
prev && prev.id === id
|
||||
? ({ ...(prev as any), ...(detailed as any) } as any)
|
||||
: prev
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e?.name === 'AbortError') return
|
||||
}
|
||||
})
|
||||
).catch(() => {})
|
||||
}
|
||||
}
|
||||
}, [selectedLogIndex, logs])
|
||||
|
||||
@@ -369,106 +221,13 @@ export default function Logs() {
|
||||
}
|
||||
}, [selectedLogIndex])
|
||||
|
||||
const fetchLogs = useCallback(async (pageNum: number, append = false) => {
|
||||
try {
|
||||
// Don't fetch if workspaceId is not set
|
||||
const { workspaceId: storeWorkspaceId } = useFilterStore.getState()
|
||||
if (!storeWorkspaceId) {
|
||||
return
|
||||
}
|
||||
|
||||
if (pageNum === 1) {
|
||||
setLoading(true)
|
||||
} else {
|
||||
setIsFetchingMore(true)
|
||||
}
|
||||
|
||||
const { buildQueryParams: getCurrentQueryParams } = useFilterStore.getState()
|
||||
const queryParams = getCurrentQueryParams(pageNum, LOGS_PER_PAGE)
|
||||
|
||||
const { searchQuery: currentSearchQuery } = useFilterStore.getState()
|
||||
const parsedQuery = parseQuery(currentSearchQuery)
|
||||
const enhancedParams = queryToApiParams(parsedQuery)
|
||||
|
||||
const allParams = new URLSearchParams(queryParams)
|
||||
Object.entries(enhancedParams).forEach(([key, value]) => {
|
||||
if (key === 'triggers' && allParams.has('triggers')) {
|
||||
const existingTriggers = allParams.get('triggers')?.split(',') || []
|
||||
const searchTriggers = value.split(',')
|
||||
const combined = [...new Set([...existingTriggers, ...searchTriggers])]
|
||||
allParams.set('triggers', combined.join(','))
|
||||
} else {
|
||||
allParams.set(key, value)
|
||||
}
|
||||
})
|
||||
|
||||
allParams.set('details', 'basic')
|
||||
const response = await fetch(`/api/logs?${allParams.toString()}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Error fetching logs: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data: LogsResponse = await response.json()
|
||||
|
||||
setHasMore(data.data.length === LOGS_PER_PAGE && data.page < data.totalPages)
|
||||
|
||||
setLogs(data.data, append)
|
||||
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch logs:', { err })
|
||||
setError(err instanceof Error ? err.message : 'An unknown error occurred')
|
||||
} finally {
|
||||
if (pageNum === 1) {
|
||||
setLoading(false)
|
||||
} else {
|
||||
setIsFetchingMore(false)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleRefresh = async () => {
|
||||
if (isRefreshing) return
|
||||
|
||||
setIsRefreshing(true)
|
||||
|
||||
try {
|
||||
await fetchLogs(1)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An unknown error occurred')
|
||||
} finally {
|
||||
setIsRefreshing(false)
|
||||
await logsQuery.refetch()
|
||||
if (selectedLog?.id) {
|
||||
await logDetailQuery.refetch()
|
||||
}
|
||||
}
|
||||
|
||||
// Setup or clear the live refresh interval when isLive changes
|
||||
useEffect(() => {
|
||||
if (liveIntervalRef.current) {
|
||||
clearInterval(liveIntervalRef.current)
|
||||
liveIntervalRef.current = null
|
||||
}
|
||||
|
||||
if (isLive) {
|
||||
handleRefresh()
|
||||
liveIntervalRef.current = setInterval(() => {
|
||||
handleRefresh()
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (liveIntervalRef.current) {
|
||||
clearInterval(liveIntervalRef.current)
|
||||
liveIntervalRef.current = null
|
||||
}
|
||||
}
|
||||
}, [isLive])
|
||||
|
||||
const toggleLive = () => {
|
||||
setIsLive(!isLive)
|
||||
}
|
||||
|
||||
const handleExport = async () => {
|
||||
const params = new URLSearchParams()
|
||||
params.set('workspaceId', workspaceId)
|
||||
@@ -506,101 +265,14 @@ export default function Logs() {
|
||||
return () => window.removeEventListener('popstate', handlePopState)
|
||||
}, [initializeFromURL])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInitialized.current) {
|
||||
return
|
||||
}
|
||||
|
||||
// Don't fetch if workspaceId is not set yet
|
||||
if (!workspaceId) {
|
||||
return
|
||||
}
|
||||
|
||||
setPage(1)
|
||||
setHasMore(true)
|
||||
|
||||
const fetchWithFilters = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
|
||||
const params = new URLSearchParams()
|
||||
params.set('details', 'basic')
|
||||
params.set('limit', LOGS_PER_PAGE.toString())
|
||||
params.set('offset', '0') // Always start from page 1
|
||||
params.set('workspaceId', workspaceId)
|
||||
|
||||
const parsedQuery = parseQuery(debouncedSearchQuery)
|
||||
const enhancedParams = queryToApiParams(parsedQuery)
|
||||
|
||||
if (level !== 'all') params.set('level', level)
|
||||
if (triggers.length > 0) params.set('triggers', triggers.join(','))
|
||||
if (workflowIds.length > 0) params.set('workflowIds', workflowIds.join(','))
|
||||
if (folderIds.length > 0) params.set('folderIds', folderIds.join(','))
|
||||
|
||||
Object.entries(enhancedParams).forEach(([key, value]) => {
|
||||
if (key === 'triggers' && params.has('triggers')) {
|
||||
const storeTriggers = params.get('triggers')?.split(',') || []
|
||||
const searchTriggers = value.split(',')
|
||||
const combined = [...new Set([...storeTriggers, ...searchTriggers])]
|
||||
params.set('triggers', combined.join(','))
|
||||
} else {
|
||||
params.set(key, value)
|
||||
}
|
||||
})
|
||||
|
||||
if (timeRange !== 'All time') {
|
||||
const now = new Date()
|
||||
let startDate: Date
|
||||
switch (timeRange) {
|
||||
case 'Past 30 minutes':
|
||||
startDate = new Date(now.getTime() - 30 * 60 * 1000)
|
||||
break
|
||||
case 'Past hour':
|
||||
startDate = new Date(now.getTime() - 60 * 60 * 1000)
|
||||
break
|
||||
case 'Past 24 hours':
|
||||
startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
break
|
||||
default:
|
||||
startDate = new Date(0)
|
||||
}
|
||||
params.set('startDate', startDate.toISOString())
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/logs?${params.toString()}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Error fetching logs: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data: LogsResponse = await response.json()
|
||||
setHasMore(data.data.length === LOGS_PER_PAGE && data.page < data.totalPages)
|
||||
setLogs(data.data, false)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch logs:', { err })
|
||||
setError(err instanceof Error ? err.message : 'An unknown error occurred')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchWithFilters()
|
||||
}, [workspaceId, timeRange, level, workflowIds, folderIds, debouncedSearchQuery, triggers])
|
||||
|
||||
const loadMoreLogs = useCallback(() => {
|
||||
if (!isFetchingMore && hasMore) {
|
||||
const nextPage = page + 1
|
||||
setPage(nextPage)
|
||||
setIsFetchingMore(true)
|
||||
setTimeout(() => {
|
||||
fetchLogs(nextPage, true)
|
||||
}, 50)
|
||||
if (!logsQuery.isFetching && logsQuery.hasNextPage) {
|
||||
logsQuery.fetchNextPage()
|
||||
}
|
||||
}, [fetchLogs, isFetchingMore, hasMore, page])
|
||||
}, [logsQuery])
|
||||
|
||||
useEffect(() => {
|
||||
if (loading || !hasMore) return
|
||||
if (logsQuery.isLoading || !logsQuery.hasNextPage) return
|
||||
|
||||
const scrollContainer = scrollContainerRef.current
|
||||
if (!scrollContainer) return
|
||||
@@ -612,7 +284,7 @@ export default function Logs() {
|
||||
|
||||
const scrollPercentage = (scrollTop / (scrollHeight - clientHeight)) * 100
|
||||
|
||||
if (scrollPercentage > 60 && !isFetchingMore && hasMore) {
|
||||
if (scrollPercentage > 60 && !logsQuery.isFetchingNextPage && logsQuery.hasNextPage) {
|
||||
loadMoreLogs()
|
||||
}
|
||||
}
|
||||
@@ -622,13 +294,14 @@ export default function Logs() {
|
||||
return () => {
|
||||
scrollContainer.removeEventListener('scroll', handleScroll)
|
||||
}
|
||||
}, [loading, hasMore, isFetchingMore, loadMoreLogs])
|
||||
}, [logsQuery.isLoading, logsQuery.hasNextPage, logsQuery.isFetchingNextPage, loadMoreLogs])
|
||||
|
||||
useEffect(() => {
|
||||
const currentLoaderRef = loaderRef.current
|
||||
const scrollContainer = scrollContainerRef.current
|
||||
|
||||
if (!currentLoaderRef || !scrollContainer || loading || !hasMore) return
|
||||
if (!currentLoaderRef || !scrollContainer || logsQuery.isLoading || !logsQuery.hasNextPage)
|
||||
return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
@@ -636,7 +309,7 @@ export default function Logs() {
|
||||
if (!e?.isIntersecting) return
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollContainer
|
||||
const pct = (scrollTop / (scrollHeight - clientHeight)) * 100
|
||||
if (pct > 70 && !isFetchingMore) {
|
||||
if (pct > 70 && !logsQuery.isFetchingNextPage) {
|
||||
loadMoreLogs()
|
||||
}
|
||||
},
|
||||
@@ -652,7 +325,7 @@ export default function Logs() {
|
||||
return () => {
|
||||
observer.unobserve(currentLoaderRef)
|
||||
}
|
||||
}, [loading, hasMore, isFetchingMore, loadMoreLogs])
|
||||
}, [logsQuery.isLoading, logsQuery.hasNextPage, logsQuery.isFetchingNextPage, loadMoreLogs])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -686,7 +359,6 @@ export default function Logs() {
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [logs, selectedLogIndex, isSidebarOpen, selectedLog, handleNavigateNext, handleNavigatePrev])
|
||||
|
||||
// If in dashboard mode, show the dashboard
|
||||
if (viewMode === 'dashboard') {
|
||||
return <Dashboard />
|
||||
}
|
||||
@@ -701,7 +373,7 @@ export default function Logs() {
|
||||
<div className='flex min-w-0 flex-1 overflow-hidden'>
|
||||
<div className='flex flex-1 flex-col p-[24px]'>
|
||||
<Controls
|
||||
isRefetching={isRefreshing}
|
||||
isRefetching={logsQuery.isFetching}
|
||||
resetToNow={handleRefresh}
|
||||
live={isLive}
|
||||
setLive={(fn) => setIsLive(fn)}
|
||||
@@ -750,18 +422,20 @@ export default function Logs() {
|
||||
|
||||
{/* Table body - scrollable */}
|
||||
<div className='flex-1 overflow-y-auto overflow-x-hidden' ref={scrollContainerRef}>
|
||||
{loading && page === 1 ? (
|
||||
{logsQuery.isLoading && !logsQuery.data ? (
|
||||
<div className='flex h-full items-center justify-center'>
|
||||
<div className='flex items-center gap-[8px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
|
||||
<Loader2 className='h-[16px] w-[16px] animate-spin' />
|
||||
<span className='text-[13px]'>Loading logs...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
) : logsQuery.isError ? (
|
||||
<div className='flex h-full items-center justify-center'>
|
||||
<div className='flex items-center gap-[8px] text-[var(--text-error)] dark:text-[var(--text-error)]'>
|
||||
<AlertCircle className='h-[16px] w-[16px]' />
|
||||
<span className='text-[13px]'>Error: {error}</span>
|
||||
<span className='text-[13px]'>
|
||||
Error: {logsQuery.error?.message || 'Failed to load logs'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : logs.length === 0 ? (
|
||||
@@ -778,7 +452,6 @@ export default function Logs() {
|
||||
const isSelected = selectedLog?.id === log.id
|
||||
const baseLevel = (log.level || 'info').toLowerCase()
|
||||
const isError = baseLevel === 'error'
|
||||
// If it's an error, don't treat it as pending even if hasPendingPause is true
|
||||
const isPending = !isError && log.hasPendingPause === true
|
||||
const statusLabel = isPending
|
||||
? 'Pending'
|
||||
@@ -906,13 +579,13 @@ export default function Logs() {
|
||||
})}
|
||||
|
||||
{/* Infinite scroll loader */}
|
||||
{hasMore && (
|
||||
{logsQuery.hasNextPage && (
|
||||
<div className='flex items-center justify-center py-[16px]'>
|
||||
<div
|
||||
ref={loaderRef}
|
||||
className='flex items-center gap-[8px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'
|
||||
>
|
||||
{isFetchingMore ? (
|
||||
{logsQuery.isFetchingNextPage ? (
|
||||
<>
|
||||
<Loader2 className='h-[16px] w-[16px] animate-spin' />
|
||||
<span className='text-[13px]'>Loading more...</span>
|
||||
@@ -932,8 +605,9 @@ export default function Logs() {
|
||||
|
||||
{/* Log Sidebar */}
|
||||
<Sidebar
|
||||
log={selectedLog}
|
||||
log={logDetailQuery.data || selectedLog}
|
||||
isOpen={isSidebarOpen}
|
||||
isLoadingDetails={logDetailQuery.isLoading}
|
||||
onClose={handleCloseSidebar}
|
||||
onNavigateNext={handleNavigateNext}
|
||||
onNavigatePrev={handleNavigatePrev}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import { useStarTemplate } from '@/hooks/queries/templates'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('TemplateCard')
|
||||
@@ -12,37 +13,21 @@ const logger = createLogger('TemplateCard')
|
||||
interface TemplateCardProps {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
author: string
|
||||
authorImageUrl?: string | null
|
||||
usageCount: string
|
||||
stars?: number
|
||||
icon?: React.ReactNode | string
|
||||
iconColor?: string
|
||||
blocks?: string[]
|
||||
onClick?: () => void
|
||||
className?: string
|
||||
// Workflow state for rendering preview
|
||||
state?: WorkflowState
|
||||
isStarred?: boolean
|
||||
// Optional callback when template is successfully used (for closing modals, etc.)
|
||||
onTemplateUsed?: () => void
|
||||
// Callback when star state changes (for parent state updates)
|
||||
onStarChange?: (templateId: string, isStarred: boolean, newStarCount: number) => void
|
||||
// User authentication status
|
||||
isAuthenticated?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton component for loading states
|
||||
*/
|
||||
export function TemplateCardSkeleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={cn('h-[268px] w-full rounded-[8px] bg-[#202020] p-[8px]', className)}>
|
||||
{/* Workflow preview skeleton */}
|
||||
<div className='h-[180px] w-full animate-pulse rounded-[6px] bg-gray-700' />
|
||||
|
||||
{/* Title and blocks row skeleton */}
|
||||
<div className='mt-[14px] flex items-center justify-between'>
|
||||
<div className='h-4 w-32 animate-pulse rounded bg-gray-700' />
|
||||
<div className='flex items-center gap-[-4px]'>
|
||||
@@ -55,7 +40,6 @@ export function TemplateCardSkeleton({ className }: { className?: string }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Creator and stats row skeleton */}
|
||||
<div className='mt-[14px] flex items-center justify-between'>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<div className='h-[14px] w-[14px] animate-pulse rounded-full bg-gray-700' />
|
||||
@@ -72,31 +56,23 @@ export function TemplateCardSkeleton({ className }: { className?: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
// Utility function to extract block types from workflow state
|
||||
const extractBlockTypesFromState = (state?: {
|
||||
blocks?: Record<string, { type: string; name?: string }>
|
||||
}): string[] => {
|
||||
if (!state?.blocks) return []
|
||||
|
||||
// Get unique block types from the state, excluding starter blocks
|
||||
// Sort the keys to ensure consistent ordering between server and client
|
||||
const blockTypes = Object.keys(state.blocks)
|
||||
.sort() // Sort keys to ensure consistent order
|
||||
.sort()
|
||||
.map((key) => state.blocks![key].type)
|
||||
.filter((type) => type !== 'starter')
|
||||
return [...new Set(blockTypes)]
|
||||
}
|
||||
|
||||
// Utility function to get the full block config for colored icon display
|
||||
const getBlockConfig = (blockType: string) => {
|
||||
const block = getBlock(blockType)
|
||||
return block
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an arbitrary workflow-like object into a valid WorkflowState for preview rendering.
|
||||
* Ensures required fields exist: blocks with required properties, edges array, loops and parallels maps.
|
||||
*/
|
||||
function normalizeWorkflowState(input?: any): WorkflowState | null {
|
||||
if (!input || !input.blocks) return null
|
||||
|
||||
@@ -142,34 +118,22 @@ function normalizeWorkflowState(input?: any): WorkflowState | null {
|
||||
function TemplateCardInner({
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
author,
|
||||
authorImageUrl,
|
||||
usageCount,
|
||||
stars = 0,
|
||||
icon,
|
||||
iconColor = 'bg-blue-500',
|
||||
blocks = [],
|
||||
onClick,
|
||||
className,
|
||||
state,
|
||||
isStarred = false,
|
||||
onTemplateUsed,
|
||||
onStarChange,
|
||||
isAuthenticated = true,
|
||||
}: TemplateCardProps) {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
|
||||
// Local state for optimistic updates
|
||||
const [localIsStarred, setLocalIsStarred] = useState(isStarred)
|
||||
const [localStarCount, setLocalStarCount] = useState(stars)
|
||||
const [isStarLoading, setIsStarLoading] = useState(false)
|
||||
const { mutate: toggleStar, isPending: isStarLoading } = useStarTemplate()
|
||||
|
||||
// Memoize normalized workflow state to avoid recalculation on every render
|
||||
const normalizedState = useMemo(() => normalizeWorkflowState(state), [state])
|
||||
|
||||
// Use IntersectionObserver to defer rendering the heavy WorkflowPreview until in viewport
|
||||
const previewRef = useRef<HTMLDivElement | null>(null)
|
||||
const [isInView, setIsInView] = useState(false)
|
||||
|
||||
@@ -188,9 +152,6 @@ function TemplateCardInner({
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
// Extract block types from state if provided, otherwise use the blocks prop
|
||||
// Filter out starter blocks in both cases and sort for consistent rendering
|
||||
// Memoized to prevent recalculation on every render
|
||||
const blockTypes = useMemo(
|
||||
() =>
|
||||
state
|
||||
@@ -199,65 +160,16 @@ function TemplateCardInner({
|
||||
[state, blocks]
|
||||
)
|
||||
|
||||
// Handle star toggle with optimistic updates
|
||||
const handleStarClick = async (e: React.MouseEvent) => {
|
||||
const handleStarClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
|
||||
// Prevent multiple clicks while loading
|
||||
if (isStarLoading) return
|
||||
|
||||
setIsStarLoading(true)
|
||||
|
||||
// Optimistic update - update UI immediately
|
||||
const newIsStarred = !localIsStarred
|
||||
const newStarCount = newIsStarred ? localStarCount + 1 : localStarCount - 1
|
||||
|
||||
setLocalIsStarred(newIsStarred)
|
||||
setLocalStarCount(newStarCount)
|
||||
|
||||
// Notify parent component immediately for optimistic update
|
||||
if (onStarChange) {
|
||||
onStarChange(id, newIsStarred, newStarCount)
|
||||
}
|
||||
|
||||
try {
|
||||
const method = localIsStarred ? 'DELETE' : 'POST'
|
||||
const response = await fetch(`/api/templates/${id}/star`, { method })
|
||||
|
||||
if (!response.ok) {
|
||||
// Rollback on error
|
||||
setLocalIsStarred(localIsStarred)
|
||||
setLocalStarCount(localStarCount)
|
||||
|
||||
// Rollback parent state too
|
||||
if (onStarChange) {
|
||||
onStarChange(id, localIsStarred, localStarCount)
|
||||
}
|
||||
|
||||
logger.error('Failed to toggle star:', response.statusText)
|
||||
}
|
||||
} catch (error) {
|
||||
// Rollback on error
|
||||
setLocalIsStarred(localIsStarred)
|
||||
setLocalStarCount(localStarCount)
|
||||
|
||||
// Rollback parent state too
|
||||
if (onStarChange) {
|
||||
onStarChange(id, localIsStarred, localStarCount)
|
||||
}
|
||||
|
||||
logger.error('Error toggling star:', error)
|
||||
} finally {
|
||||
setIsStarLoading(false)
|
||||
}
|
||||
toggleStar({
|
||||
templateId: id,
|
||||
action: isStarred ? 'remove' : 'add',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate template detail page URL based on context.
|
||||
* If we're in a workspace context, navigate to the workspace template page.
|
||||
* Otherwise, navigate to the global template page.
|
||||
* Memoized to avoid recalculation on every render.
|
||||
*/
|
||||
const templateUrl = useMemo(() => {
|
||||
const workspaceId = params?.workspaceId as string | undefined
|
||||
if (workspaceId) {
|
||||
@@ -266,23 +178,8 @@ function TemplateCardInner({
|
||||
return `/templates/${id}`
|
||||
}, [params?.workspaceId, id])
|
||||
|
||||
/**
|
||||
* Handle use button click - navigate to template detail page
|
||||
*/
|
||||
const handleUseClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
router.push(templateUrl)
|
||||
},
|
||||
[router, templateUrl]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handle card click - navigate to template detail page
|
||||
*/
|
||||
const handleCardClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
// Don't navigate if clicking on action buttons
|
||||
const target = e.target as HTMLElement
|
||||
if (target.closest('button') || target.closest('[data-action]')) {
|
||||
return
|
||||
@@ -298,7 +195,6 @@ function TemplateCardInner({
|
||||
onClick={handleCardClick}
|
||||
className={cn('w-full cursor-pointer rounded-[8px] bg-[#202020] p-[8px]', className)}
|
||||
>
|
||||
{/* Workflow Preview */}
|
||||
<div
|
||||
ref={previewRef}
|
||||
className='pointer-events-none h-[180px] w-full overflow-hidden rounded-[6px]'
|
||||
@@ -318,16 +214,12 @@ function TemplateCardInner({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title and Blocks Row */}
|
||||
<div className='mt-[10px] flex items-center justify-between'>
|
||||
{/* Template Name */}
|
||||
<h3 className='truncate pr-[8px] pl-[2px] font-medium text-[16px] text-white'>{title}</h3>
|
||||
|
||||
{/* Block Icons */}
|
||||
<div className='flex flex-shrink-0'>
|
||||
{blockTypes.length > 4 ? (
|
||||
<>
|
||||
{/* Show first 3 blocks when there are more than 4 */}
|
||||
{blockTypes.slice(0, 3).map((blockType, index) => {
|
||||
const blockConfig = getBlockConfig(blockType)
|
||||
if (!blockConfig) return null
|
||||
@@ -345,7 +237,6 @@ function TemplateCardInner({
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{/* Show +n for remaining blocks */}
|
||||
<div
|
||||
className='flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-[4px] bg-[#4A4A4A]'
|
||||
style={{ marginLeft: '-4px' }}
|
||||
@@ -354,7 +245,6 @@ function TemplateCardInner({
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
/* Show all blocks when 4 or fewer */
|
||||
blockTypes.map((blockType, index) => {
|
||||
const blockConfig = getBlockConfig(blockType)
|
||||
if (!blockConfig) return null
|
||||
@@ -376,9 +266,7 @@ function TemplateCardInner({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Creator and Stats Row */}
|
||||
<div className='mt-[10px] flex items-center justify-between'>
|
||||
{/* Creator Info */}
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
{authorImageUrl ? (
|
||||
<div className='h-[26px] w-[26px] flex-shrink-0 overflow-hidden rounded-full'>
|
||||
@@ -392,7 +280,6 @@ function TemplateCardInner({
|
||||
<span className='truncate font-medium text-[#888888] text-[12px]'>{author}</span>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className='flex flex-shrink-0 items-center gap-[6px] font-medium text-[#888888] text-[12px]'>
|
||||
<User className='h-[12px] w-[12px]' />
|
||||
<span>{usageCount}</span>
|
||||
@@ -400,11 +287,11 @@ function TemplateCardInner({
|
||||
onClick={handleStarClick}
|
||||
className={cn(
|
||||
'h-[12px] w-[12px] cursor-pointer transition-colors',
|
||||
localIsStarred ? 'fill-yellow-500 text-yellow-500' : 'text-[#888888]',
|
||||
isStarred ? 'fill-yellow-500 text-yellow-500' : 'text-[#888888]',
|
||||
isStarLoading && 'opacity-50'
|
||||
)}
|
||||
/>
|
||||
<span>{localStarCount}</span>
|
||||
<span>{stars}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
TemplateCard,
|
||||
TemplateCardSkeleton,
|
||||
} from '@/app/workspace/[workspaceId]/templates/components/template-card'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
import type { CreatorProfileDetails } from '@/types/creator-profile'
|
||||
|
||||
@@ -70,30 +71,19 @@ export default function Templates({
|
||||
isSuperUser,
|
||||
}: TemplatesProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 300)
|
||||
const [activeTab, setActiveTab] = useState('gallery')
|
||||
const [templates, setTemplates] = useState<Template[]>(initialTemplates)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
/**
|
||||
* Update star status for a template
|
||||
*/
|
||||
const handleStarChange = (templateId: string, isStarred: boolean, newStarCount: number) => {
|
||||
setTemplates((prevTemplates) =>
|
||||
prevTemplates.map((template) =>
|
||||
template.id === templateId ? { ...template, isStarred, stars: newStarCount } : template
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter templates based on active tab and search query
|
||||
* Memoized to prevent unnecessary recalculations on render
|
||||
*/
|
||||
const filteredTemplates = useMemo(() => {
|
||||
const query = searchQuery.toLowerCase()
|
||||
const query = debouncedSearchQuery.toLowerCase()
|
||||
|
||||
return templates.filter((template) => {
|
||||
// Filter by tab
|
||||
const tabMatch =
|
||||
activeTab === 'your'
|
||||
? template.userId === currentUserId || template.isStarred
|
||||
@@ -103,7 +93,6 @@ export default function Templates({
|
||||
|
||||
if (!tabMatch) return false
|
||||
|
||||
// Filter by search query
|
||||
if (!query) return true
|
||||
|
||||
const searchableText = [
|
||||
@@ -119,14 +108,14 @@ export default function Templates({
|
||||
|
||||
return searchableText.includes(query)
|
||||
})
|
||||
}, [templates, activeTab, searchQuery, currentUserId])
|
||||
}, [templates, activeTab, debouncedSearchQuery, currentUserId])
|
||||
|
||||
/**
|
||||
* Get empty state message based on current filters
|
||||
* Memoized to prevent unnecessary recalculations on render
|
||||
*/
|
||||
const emptyState = useMemo(() => {
|
||||
if (searchQuery) {
|
||||
if (debouncedSearchQuery) {
|
||||
return {
|
||||
title: 'No templates found',
|
||||
description: 'Try a different search term',
|
||||
@@ -149,7 +138,7 @@ export default function Templates({
|
||||
}
|
||||
|
||||
return messages[activeTab as keyof typeof messages] || messages.gallery
|
||||
}, [searchQuery, activeTab])
|
||||
}, [debouncedSearchQuery, activeTab])
|
||||
|
||||
return (
|
||||
<div className='flex h-[100vh] flex-col pl-64'>
|
||||
@@ -228,17 +217,12 @@ export default function Templates({
|
||||
key={template.id}
|
||||
id={template.id}
|
||||
title={template.name}
|
||||
description={template.description || template.details?.tagline || ''}
|
||||
author={author}
|
||||
authorImageUrl={authorImageUrl}
|
||||
usageCount={template.views.toString()}
|
||||
stars={template.stars}
|
||||
icon={template.icon}
|
||||
iconColor={template.color}
|
||||
state={template.state}
|
||||
isStarred={template.isStarred}
|
||||
onStarChange={handleStarChange}
|
||||
isAuthenticated={true}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -600,6 +600,7 @@ export function Chat() {
|
||||
onOutputSelect={handleOutputSelection}
|
||||
disabled={!activeWorkflowId}
|
||||
placeholder='Select outputs'
|
||||
align='end'
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverItem,
|
||||
PopoverScrollArea,
|
||||
PopoverSection,
|
||||
PopoverTrigger,
|
||||
} from '@/components/emcn'
|
||||
@@ -24,6 +23,7 @@ interface OutputSelectProps {
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
valueMode?: 'id' | 'label'
|
||||
align?: 'start' | 'end' | 'center'
|
||||
}
|
||||
|
||||
export function OutputSelect({
|
||||
@@ -33,10 +33,13 @@ export function OutputSelect({
|
||||
disabled = false,
|
||||
placeholder = 'Select outputs',
|
||||
valueMode = 'id',
|
||||
align = 'start',
|
||||
}: OutputSelectProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(-1)
|
||||
const triggerRef = useRef<HTMLDivElement>(null)
|
||||
const popoverRef = useRef<HTMLDivElement>(null)
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
const blocks = useWorkflowStore((state) => state.blocks)
|
||||
const { isShowingDiff, isDiffReady, diffWorkflow } = useWorkflowDiffStore()
|
||||
const subBlockValues = useSubBlockStore((state) =>
|
||||
@@ -230,6 +233,13 @@ export function OutputSelect({
|
||||
return blockConfig?.bgColor || '#2F55FF'
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattened outputs for keyboard navigation
|
||||
*/
|
||||
const flattenedOutputs = useMemo(() => {
|
||||
return Object.values(groupedOutputs).flat()
|
||||
}, [groupedOutputs])
|
||||
|
||||
/**
|
||||
* Handles output selection - toggle selection
|
||||
*/
|
||||
@@ -246,6 +256,75 @@ export function OutputSelect({
|
||||
onOutputSelect(newSelectedOutputs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyboard navigation handler
|
||||
*/
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (flattenedOutputs.length === 0) return
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
setHighlightedIndex((prev) => {
|
||||
const next = prev < flattenedOutputs.length - 1 ? prev + 1 : 0
|
||||
return next
|
||||
})
|
||||
break
|
||||
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
setHighlightedIndex((prev) => {
|
||||
const next = prev > 0 ? prev - 1 : flattenedOutputs.length - 1
|
||||
return next
|
||||
})
|
||||
break
|
||||
|
||||
case 'Enter':
|
||||
e.preventDefault()
|
||||
if (highlightedIndex >= 0 && highlightedIndex < flattenedOutputs.length) {
|
||||
handleOutputSelection(flattenedOutputs[highlightedIndex].label)
|
||||
}
|
||||
break
|
||||
|
||||
case 'Escape':
|
||||
e.preventDefault()
|
||||
setOpen(false)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset highlighted index when popover opens/closes
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
// Find first selected item, or start at -1
|
||||
const firstSelectedIndex = flattenedOutputs.findIndex((output) => isSelectedValue(output))
|
||||
setHighlightedIndex(firstSelectedIndex >= 0 ? firstSelectedIndex : -1)
|
||||
|
||||
// Focus the content for keyboard navigation
|
||||
setTimeout(() => {
|
||||
contentRef.current?.focus()
|
||||
}, 0)
|
||||
} else {
|
||||
setHighlightedIndex(-1)
|
||||
}
|
||||
}, [open, flattenedOutputs])
|
||||
|
||||
/**
|
||||
* Scroll highlighted item into view
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (highlightedIndex >= 0 && contentRef.current) {
|
||||
const highlightedElement = contentRef.current.querySelector(
|
||||
`[data-option-index="${highlightedIndex}"]`
|
||||
)
|
||||
if (highlightedElement) {
|
||||
highlightedElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
}
|
||||
}
|
||||
}, [highlightedIndex])
|
||||
|
||||
/**
|
||||
* Closes popover when clicking outside
|
||||
*/
|
||||
@@ -288,44 +367,57 @@ export function OutputSelect({
|
||||
<PopoverContent
|
||||
ref={popoverRef}
|
||||
side='bottom'
|
||||
align='start'
|
||||
align={align}
|
||||
sideOffset={4}
|
||||
maxHeight={140}
|
||||
maxWidth={140}
|
||||
minWidth={140}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
maxHeight={300}
|
||||
maxWidth={300}
|
||||
minWidth={200}
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={0}
|
||||
style={{ outline: 'none' }}
|
||||
>
|
||||
<PopoverScrollArea className='space-y-[2px]'>
|
||||
{Object.entries(groupedOutputs).map(([blockName, outputs]) => (
|
||||
<div key={blockName}>
|
||||
<PopoverSection>{blockName}</PopoverSection>
|
||||
<div ref={contentRef} className='space-y-[2px]'>
|
||||
{Object.entries(groupedOutputs).map(([blockName, outputs]) => {
|
||||
// Calculate the starting index for this group
|
||||
const startIndex = flattenedOutputs.findIndex((o) => o.blockName === blockName)
|
||||
|
||||
<div className='flex flex-col gap-[2px]'>
|
||||
{outputs.map((output) => (
|
||||
<PopoverItem
|
||||
key={output.id}
|
||||
active={isSelectedValue(output)}
|
||||
onClick={() => handleOutputSelection(output.label)}
|
||||
>
|
||||
<div
|
||||
className='flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center rounded'
|
||||
style={{
|
||||
backgroundColor: getOutputColor(output.blockId, output.blockType),
|
||||
}}
|
||||
>
|
||||
<span className='font-bold text-[10px] text-white'>
|
||||
{blockName.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<span className='min-w-0 flex-1 truncate'>{output.path}</span>
|
||||
{isSelectedValue(output) && <Check className='h-3 w-3 flex-shrink-0' />}
|
||||
</PopoverItem>
|
||||
))}
|
||||
return (
|
||||
<div key={blockName}>
|
||||
<PopoverSection>{blockName}</PopoverSection>
|
||||
|
||||
<div className='flex flex-col gap-[2px]'>
|
||||
{outputs.map((output, localIndex) => {
|
||||
const globalIndex = startIndex + localIndex
|
||||
const isHighlighted = globalIndex === highlightedIndex
|
||||
|
||||
return (
|
||||
<PopoverItem
|
||||
key={output.id}
|
||||
active={isSelectedValue(output) || isHighlighted}
|
||||
data-option-index={globalIndex}
|
||||
onClick={() => handleOutputSelection(output.label)}
|
||||
onMouseEnter={() => setHighlightedIndex(globalIndex)}
|
||||
>
|
||||
<div
|
||||
className='flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center rounded'
|
||||
style={{
|
||||
backgroundColor: getOutputColor(output.blockId, output.blockType),
|
||||
}}
|
||||
>
|
||||
<span className='font-bold text-[10px] text-white'>
|
||||
{blockName.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<span className='min-w-0 flex-1 truncate'>{output.path}</span>
|
||||
{isSelectedValue(output) && <Check className='h-3 w-3 flex-shrink-0' />}
|
||||
</PopoverItem>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</PopoverScrollArea>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
|
||||
@@ -27,6 +27,12 @@ import { TagInput } from '@/components/ui/tag-input'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
|
||||
import {
|
||||
useCreateTemplate,
|
||||
useDeleteTemplate,
|
||||
useTemplateByWorkflow,
|
||||
useUpdateTemplate,
|
||||
} from '@/hooks/queries/templates'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('TemplateDeploy')
|
||||
@@ -55,15 +61,16 @@ interface TemplateDeployProps {
|
||||
|
||||
export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDeployProps) {
|
||||
const { data: session } = useSession()
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [existingTemplate, setExistingTemplate] = useState<any>(null)
|
||||
const [isLoadingTemplate, setIsLoadingTemplate] = useState(false)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [creatorOptions, setCreatorOptions] = useState<CreatorOption[]>([])
|
||||
const [loadingCreators, setLoadingCreators] = useState(false)
|
||||
const [showPreviewDialog, setShowPreviewDialog] = useState(false)
|
||||
|
||||
const { data: existingTemplate, isLoading: isLoadingTemplate } = useTemplateByWorkflow(workflowId)
|
||||
const createMutation = useCreateTemplate()
|
||||
const updateMutation = useUpdateTemplate()
|
||||
const deleteMutation = useDeleteTemplate()
|
||||
|
||||
const form = useForm<TemplateFormData>({
|
||||
resolver: zodResolver(templateSchema),
|
||||
defaultValues: {
|
||||
@@ -75,7 +82,6 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
|
||||
},
|
||||
})
|
||||
|
||||
// Fetch creator profiles
|
||||
const fetchCreatorOptions = async () => {
|
||||
if (!session?.user?.id) return
|
||||
|
||||
@@ -105,7 +111,6 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
|
||||
fetchCreatorOptions()
|
||||
}, [session?.user?.id])
|
||||
|
||||
// Auto-select creator profile when there's only one option and no selection yet
|
||||
useEffect(() => {
|
||||
const currentCreatorId = form.getValues('creatorId')
|
||||
if (creatorOptions.length === 1 && !currentCreatorId) {
|
||||
@@ -114,15 +119,12 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
|
||||
}
|
||||
}, [creatorOptions, form])
|
||||
|
||||
// Listen for creator profile saved event
|
||||
useEffect(() => {
|
||||
const handleCreatorProfileSaved = async () => {
|
||||
logger.info('Creator profile saved, refreshing profiles...')
|
||||
|
||||
// Refetch creator profiles (autoselection will happen via the effect above)
|
||||
await fetchCreatorOptions()
|
||||
|
||||
// Close settings modal and reopen deploy modal to template tab
|
||||
window.dispatchEvent(new CustomEvent('close-settings'))
|
||||
setTimeout(() => {
|
||||
window.dispatchEvent(new CustomEvent('open-deploy-modal', { detail: { tab: 'template' } }))
|
||||
@@ -136,41 +138,20 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Check for existing template
|
||||
useEffect(() => {
|
||||
const checkExistingTemplate = async () => {
|
||||
setIsLoadingTemplate(true)
|
||||
try {
|
||||
const response = await fetch(`/api/templates?workflowId=${workflowId}&limit=1`)
|
||||
if (response.ok) {
|
||||
const result = await response.json()
|
||||
const template = result.data?.[0] || null
|
||||
setExistingTemplate(template)
|
||||
if (existingTemplate) {
|
||||
const tagline = existingTemplate.details?.tagline || ''
|
||||
const about = existingTemplate.details?.about || ''
|
||||
|
||||
if (template) {
|
||||
// Map old template format to new format if needed
|
||||
const tagline = (template.details as any)?.tagline || template.description || ''
|
||||
const about = (template.details as any)?.about || ''
|
||||
|
||||
form.reset({
|
||||
name: template.name,
|
||||
tagline: tagline,
|
||||
about: about,
|
||||
creatorId: template.creatorId || undefined,
|
||||
tags: template.tags || [],
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error checking existing template:', error)
|
||||
setExistingTemplate(null)
|
||||
} finally {
|
||||
setIsLoadingTemplate(false)
|
||||
}
|
||||
form.reset({
|
||||
name: existingTemplate.name,
|
||||
tagline: tagline,
|
||||
about: about,
|
||||
creatorId: existingTemplate.creatorId || undefined,
|
||||
tags: existingTemplate.tags || [],
|
||||
})
|
||||
}
|
||||
|
||||
checkExistingTemplate()
|
||||
}, [workflowId, session?.user?.id])
|
||||
}, [existingTemplate, form])
|
||||
|
||||
const onSubmit = async (data: TemplateFormData) => {
|
||||
if (!session?.user) {
|
||||
@@ -178,85 +159,51 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
// Build template data with new schema
|
||||
const templateData: any = {
|
||||
const templateData = {
|
||||
name: data.name,
|
||||
details: {
|
||||
tagline: data.tagline || '',
|
||||
about: data.about || '',
|
||||
},
|
||||
creatorId: data.creatorId || null,
|
||||
creatorId: data.creatorId || undefined,
|
||||
tags: data.tags || [],
|
||||
}
|
||||
|
||||
let response
|
||||
if (existingTemplate) {
|
||||
// Update template metadata AND state from current workflow
|
||||
response = await fetch(`/api/templates/${existingTemplate.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
await updateMutation.mutateAsync({
|
||||
id: existingTemplate.id,
|
||||
data: {
|
||||
...templateData,
|
||||
updateState: true, // Update state from current workflow
|
||||
}),
|
||||
updateState: true,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
// Create new template with workflowId
|
||||
response = await fetch('/api/templates', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...templateData, workflowId }),
|
||||
})
|
||||
await createMutation.mutateAsync({ ...templateData, workflowId })
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(
|
||||
errorData.error || `Failed to ${existingTemplate ? 'update' : 'create'} template`
|
||||
)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
logger.info(`Template ${existingTemplate ? 'updated' : 'created'} successfully:`, result)
|
||||
|
||||
// Update existing template state
|
||||
setExistingTemplate(result.data || result)
|
||||
|
||||
logger.info(`Template ${existingTemplate ? 'updated' : 'created'} successfully`)
|
||||
onDeploymentComplete?.()
|
||||
} catch (error) {
|
||||
logger.error('Failed to save template:', error)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!existingTemplate) return
|
||||
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
const response = await fetch(`/api/templates/${existingTemplate.id}`, {
|
||||
method: 'DELETE',
|
||||
await deleteMutation.mutateAsync(existingTemplate.id)
|
||||
setShowDeleteDialog(false)
|
||||
form.reset({
|
||||
name: '',
|
||||
tagline: '',
|
||||
about: '',
|
||||
creatorId: undefined,
|
||||
tags: [],
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
setExistingTemplate(null)
|
||||
setShowDeleteDialog(false)
|
||||
form.reset({
|
||||
name: '',
|
||||
tagline: '',
|
||||
about: '',
|
||||
creatorId: undefined,
|
||||
tags: [],
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error deleting template:', error)
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -422,7 +369,7 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
|
||||
onChange={field.onChange}
|
||||
placeholder='Type and press Enter to add tags'
|
||||
maxTags={10}
|
||||
disabled={isSubmitting}
|
||||
disabled={createMutation.isPending || updateMutation.isPending}
|
||||
/>
|
||||
</FormControl>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
@@ -447,9 +394,11 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
|
||||
<Button
|
||||
type='submit'
|
||||
variant='primary'
|
||||
disabled={isSubmitting || !form.formState.isValid}
|
||||
disabled={
|
||||
createMutation.isPending || updateMutation.isPending || !form.formState.isValid
|
||||
}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
{createMutation.isPending || updateMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className='mr-[8px] h-[14px] w-[14px] animate-spin' />
|
||||
{existingTemplate ? 'Updating...' : 'Publishing...'}
|
||||
@@ -479,10 +428,10 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
disabled={deleteMutation.isPending}
|
||||
className='bg-red-600 text-white hover:bg-red-700'
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -511,7 +460,6 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
|
||||
)
|
||||
}
|
||||
|
||||
// Ensure the state has the right structure
|
||||
const workflowState: WorkflowState = {
|
||||
blocks: existingTemplate.state.blocks || {},
|
||||
edges: existingTemplate.state.edges || [],
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export { DeployModal } from './deploy-modal/deploy-modal'
|
||||
export { DeploymentControls } from './deployment-controls/deployment-controls'
|
||||
export { ExportControls } from './export-controls/export-controls'
|
||||
export { TemplateModal } from './template-modal/template-modal'
|
||||
export { WebhookSettings } from './webhook-settings/webhook-settings'
|
||||
|
||||
@@ -1,756 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import {
|
||||
Award,
|
||||
BarChart3,
|
||||
Bell,
|
||||
BookOpen,
|
||||
Bot,
|
||||
Brain,
|
||||
Briefcase,
|
||||
Calculator,
|
||||
Cloud,
|
||||
Code,
|
||||
Cpu,
|
||||
CreditCard,
|
||||
Database,
|
||||
DollarSign,
|
||||
Edit,
|
||||
Eye,
|
||||
FileText,
|
||||
Folder,
|
||||
Globe,
|
||||
HeadphonesIcon,
|
||||
Layers,
|
||||
Lightbulb,
|
||||
LineChart,
|
||||
Loader2,
|
||||
Mail,
|
||||
Megaphone,
|
||||
MessageSquare,
|
||||
NotebookPen,
|
||||
Phone,
|
||||
Play,
|
||||
Search,
|
||||
Server,
|
||||
Settings,
|
||||
ShoppingCart,
|
||||
Star,
|
||||
Target,
|
||||
TrendingUp,
|
||||
User,
|
||||
Users,
|
||||
Workflow,
|
||||
Wrench,
|
||||
X,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ColorPicker } from '@/components/ui/color-picker'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { buildWorkflowStateForTemplate } from '@/lib/workflows/state-builder'
|
||||
|
||||
const logger = createLogger('TemplateModal')
|
||||
|
||||
const templateSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required').max(100, 'Name must be less than 100 characters'),
|
||||
description: z
|
||||
.string()
|
||||
.min(1, 'Description is required')
|
||||
.max(500, 'Description must be less than 500 characters'),
|
||||
author: z
|
||||
.string()
|
||||
.min(1, 'Author is required')
|
||||
.max(100, 'Author must be less than 100 characters'),
|
||||
authorType: z.enum(['user', 'organization']).default('user'),
|
||||
organizationId: z.string().optional(),
|
||||
icon: z.string().min(1, 'Icon is required'),
|
||||
color: z.string().regex(/^#[0-9A-F]{6}$/i, 'Color must be a valid hex color (e.g., #3972F6)'),
|
||||
})
|
||||
|
||||
type TemplateFormData = z.infer<typeof templateSchema>
|
||||
|
||||
interface Organization {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface TemplateModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
workflowId: string
|
||||
}
|
||||
|
||||
const icons = [
|
||||
// Content & Documentation
|
||||
{ value: 'FileText', label: 'File Text', component: FileText },
|
||||
{ value: 'NotebookPen', label: 'Notebook', component: NotebookPen },
|
||||
{ value: 'BookOpen', label: 'Book', component: BookOpen },
|
||||
{ value: 'Edit', label: 'Edit', component: Edit },
|
||||
|
||||
// Analytics & Charts
|
||||
{ value: 'BarChart3', label: 'Bar Chart', component: BarChart3 },
|
||||
{ value: 'LineChart', label: 'Line Chart', component: LineChart },
|
||||
{ value: 'TrendingUp', label: 'Trending Up', component: TrendingUp },
|
||||
{ value: 'Target', label: 'Target', component: Target },
|
||||
|
||||
// Database & Storage
|
||||
{ value: 'Database', label: 'Database', component: Database },
|
||||
{ value: 'Server', label: 'Server', component: Server },
|
||||
{ value: 'Cloud', label: 'Cloud', component: Cloud },
|
||||
{ value: 'Folder', label: 'Folder', component: Folder },
|
||||
|
||||
// Marketing & Communication
|
||||
{ value: 'Megaphone', label: 'Megaphone', component: Megaphone },
|
||||
{ value: 'Mail', label: 'Mail', component: Mail },
|
||||
{ value: 'MessageSquare', label: 'Message', component: MessageSquare },
|
||||
{ value: 'Phone', label: 'Phone', component: Phone },
|
||||
{ value: 'Bell', label: 'Bell', component: Bell },
|
||||
|
||||
// Sales & Finance
|
||||
{ value: 'DollarSign', label: 'Dollar Sign', component: DollarSign },
|
||||
{ value: 'CreditCard', label: 'Credit Card', component: CreditCard },
|
||||
{ value: 'Calculator', label: 'Calculator', component: Calculator },
|
||||
{ value: 'ShoppingCart', label: 'Shopping Cart', component: ShoppingCart },
|
||||
{ value: 'Briefcase', label: 'Briefcase', component: Briefcase },
|
||||
|
||||
// Support & Service
|
||||
{ value: 'HeadphonesIcon', label: 'Headphones', component: HeadphonesIcon },
|
||||
{ value: 'User', label: 'User', component: User },
|
||||
{ value: 'Users', label: 'Users', component: Users },
|
||||
{ value: 'Settings', label: 'Settings', component: Settings },
|
||||
{ value: 'Wrench', label: 'Wrench', component: Wrench },
|
||||
|
||||
// AI & Technology
|
||||
{ value: 'Bot', label: 'Bot', component: Bot },
|
||||
{ value: 'Brain', label: 'Brain', component: Brain },
|
||||
{ value: 'Cpu', label: 'CPU', component: Cpu },
|
||||
{ value: 'Code', label: 'Code', component: Code },
|
||||
{ value: 'Zap', label: 'Zap', component: Zap },
|
||||
|
||||
// Workflow & Process
|
||||
{ value: 'Workflow', label: 'Workflow', component: Workflow },
|
||||
{ value: 'Search', label: 'Search', component: Search },
|
||||
{ value: 'Play', label: 'Play', component: Play },
|
||||
{ value: 'Layers', label: 'Layers', component: Layers },
|
||||
|
||||
// General
|
||||
{ value: 'Lightbulb', label: 'Lightbulb', component: Lightbulb },
|
||||
{ value: 'Star', label: 'Star', component: Star },
|
||||
{ value: 'Globe', label: 'Globe', component: Globe },
|
||||
{ value: 'Award', label: 'Award', component: Award },
|
||||
]
|
||||
|
||||
export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalProps) {
|
||||
const { data: session } = useSession()
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [iconPopoverOpen, setIconPopoverOpen] = useState(false)
|
||||
const [existingTemplate, setExistingTemplate] = useState<any>(null)
|
||||
const [isLoadingTemplate, setIsLoadingTemplate] = useState(false)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [organizations, setOrganizations] = useState<Organization[]>([])
|
||||
const [loadingOrgs, setLoadingOrgs] = useState(false)
|
||||
|
||||
const form = useForm<TemplateFormData>({
|
||||
resolver: zodResolver(templateSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
description: '',
|
||||
author: session?.user?.name || session?.user?.email || '',
|
||||
authorType: 'user',
|
||||
organizationId: undefined,
|
||||
icon: 'FileText',
|
||||
color: '#3972F6',
|
||||
},
|
||||
})
|
||||
|
||||
// Watch form state to determine if all required fields are valid
|
||||
const formValues = form.watch()
|
||||
const authorType = form.watch('authorType')
|
||||
const isFormValid =
|
||||
form.formState.isValid &&
|
||||
formValues.name?.trim() &&
|
||||
formValues.description?.trim() &&
|
||||
formValues.author?.trim()
|
||||
|
||||
// Fetch user's organizations when modal opens
|
||||
useEffect(() => {
|
||||
const fetchOrganizations = async () => {
|
||||
if (!open || !session?.user?.id) return
|
||||
|
||||
setLoadingOrgs(true)
|
||||
try {
|
||||
const response = await fetch('/api/organizations')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setOrganizations(data.organizations || [])
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching organizations:', error)
|
||||
setOrganizations([])
|
||||
} finally {
|
||||
setLoadingOrgs(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (open) {
|
||||
fetchOrganizations()
|
||||
}
|
||||
}, [open, session?.user?.id])
|
||||
|
||||
// Check for existing template when modal opens
|
||||
useEffect(() => {
|
||||
if (open && workflowId) {
|
||||
checkExistingTemplate()
|
||||
}
|
||||
}, [open, workflowId])
|
||||
|
||||
const checkExistingTemplate = async () => {
|
||||
setIsLoadingTemplate(true)
|
||||
try {
|
||||
const response = await fetch(`/api/templates?workflowId=${workflowId}&limit=1`)
|
||||
if (response.ok) {
|
||||
const result = await response.json()
|
||||
const template = result.data?.[0] || null
|
||||
setExistingTemplate(template)
|
||||
|
||||
// Pre-fill form with existing template data
|
||||
if (template) {
|
||||
form.reset({
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
author: template.author,
|
||||
authorType: template.authorType || 'user',
|
||||
organizationId: template.organizationId || undefined,
|
||||
icon: template.icon,
|
||||
color: template.color,
|
||||
})
|
||||
} else {
|
||||
// No existing template found
|
||||
setExistingTemplate(null)
|
||||
// Reset form to defaults
|
||||
form.reset({
|
||||
name: '',
|
||||
description: '',
|
||||
author: session?.user?.name || session?.user?.email || '',
|
||||
authorType: 'user',
|
||||
organizationId: undefined,
|
||||
icon: 'FileText',
|
||||
color: '#3972F6',
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error checking existing template:', error)
|
||||
setExistingTemplate(null)
|
||||
} finally {
|
||||
setIsLoadingTemplate(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onSubmit = async (data: TemplateFormData) => {
|
||||
if (!session?.user) {
|
||||
logger.error('User not authenticated')
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
// Create the template state from current workflow using the same format as deployment
|
||||
const templateState = buildWorkflowStateForTemplate(workflowId)
|
||||
|
||||
const templateData = {
|
||||
workflowId,
|
||||
name: data.name,
|
||||
description: data.description || '',
|
||||
author: data.author,
|
||||
authorType: data.authorType,
|
||||
organizationId: data.organizationId,
|
||||
icon: data.icon,
|
||||
color: data.color,
|
||||
state: templateState,
|
||||
}
|
||||
|
||||
let response
|
||||
if (existingTemplate) {
|
||||
// Update existing template
|
||||
response = await fetch(`/api/templates/${existingTemplate.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(templateData),
|
||||
})
|
||||
} else {
|
||||
// Create new template
|
||||
response = await fetch('/api/templates', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(templateData),
|
||||
})
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(
|
||||
errorData.error || `Failed to ${existingTemplate ? 'update' : 'create'} template`
|
||||
)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
logger.info(`Template ${existingTemplate ? 'updated' : 'created'} successfully:`, result)
|
||||
|
||||
// Reset form and close modal
|
||||
form.reset()
|
||||
onOpenChange(false)
|
||||
|
||||
// TODO: Show success toast/notification
|
||||
} catch (error) {
|
||||
logger.error('Failed to create template:', error)
|
||||
// TODO: Show error toast/notification
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const SelectedIconComponent =
|
||||
icons.find((icon) => icon.value === form.watch('icon'))?.component || FileText
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className='flex h-[70vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-[600px]'
|
||||
hideCloseButton
|
||||
>
|
||||
<DialogHeader className='flex-shrink-0 border-b px-6 py-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<DialogTitle className='font-medium text-lg'>
|
||||
{isLoadingTemplate
|
||||
? 'Loading...'
|
||||
: existingTemplate
|
||||
? 'Update Template'
|
||||
: 'Publish Template'}
|
||||
</DialogTitle>
|
||||
{existingTemplate && (
|
||||
<div className='flex items-center gap-2'>
|
||||
{existingTemplate.stars > 0 && (
|
||||
<div className='flex items-center gap-1 rounded-full bg-yellow-50 px-2 py-1 dark:bg-yellow-900/20'>
|
||||
<Star className='h-3 w-3 fill-yellow-400 text-yellow-400' />
|
||||
<span className='font-medium text-xs text-yellow-700 dark:text-yellow-300'>
|
||||
{existingTemplate.stars}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{existingTemplate.views > 0 && (
|
||||
<div className='flex items-center gap-1 rounded-full bg-blue-50 px-2 py-1 dark:bg-blue-900/20'>
|
||||
<Eye className='h-3 w-3 text-blue-500' />
|
||||
<span className='font-medium text-blue-700 text-xs dark:text-blue-300'>
|
||||
{existingTemplate.views}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className={cn(
|
||||
'h-8 w-8 rounded-md p-0 text-muted-foreground/70 transition-all duration-200',
|
||||
'hover:scale-105 hover:bg-muted/50 hover:text-foreground',
|
||||
'active:scale-95',
|
||||
'focus-visible:ring-2 focus-visible:ring-muted-foreground/20 focus-visible:ring-offset-1'
|
||||
)}
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<X className='h-4 w-4' />
|
||||
<span className='sr-only'>Close</span>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className='flex flex-1 flex-col overflow-hidden'
|
||||
>
|
||||
<div className='flex-1 overflow-y-auto px-6 py-4'>
|
||||
{isLoadingTemplate ? (
|
||||
<div className='space-y-6'>
|
||||
{/* Icon and Color row */}
|
||||
<div className='flex gap-3'>
|
||||
<div className='w-20'>
|
||||
<Skeleton className='mb-2 h-4 w-8' /> {/* Label */}
|
||||
<Skeleton className='h-10 w-20' /> {/* Icon picker */}
|
||||
</div>
|
||||
<div className='w-20'>
|
||||
<Skeleton className='mb-2 h-4 w-10' /> {/* Label */}
|
||||
<Skeleton className='h-10 w-20' /> {/* Color picker */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Name field */}
|
||||
<div>
|
||||
<Skeleton className='mb-2 h-4 w-12' /> {/* Label */}
|
||||
<Skeleton className='h-10 w-full' /> {/* Input */}
|
||||
</div>
|
||||
|
||||
{/* Author and Author Type row */}
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<div>
|
||||
<Skeleton className='mb-2 h-4 w-14' /> {/* Label */}
|
||||
<Skeleton className='h-10 w-full' /> {/* Input */}
|
||||
</div>
|
||||
<div>
|
||||
<Skeleton className='mb-2 h-4 w-24' /> {/* Label */}
|
||||
<Skeleton className='h-10 w-full' /> {/* Select */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description field */}
|
||||
<div>
|
||||
<Skeleton className='mb-2 h-4 w-20' /> {/* Label */}
|
||||
<Skeleton className='h-20 w-full' /> {/* Textarea */}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-5'>
|
||||
<div className='flex gap-3'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='icon'
|
||||
render={({ field }) => (
|
||||
<FormItem className='w-20'>
|
||||
<FormLabel className='!text-foreground font-medium text-sm'>
|
||||
Icon
|
||||
</FormLabel>
|
||||
<Popover open={iconPopoverOpen} onOpenChange={setIconPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
role='combobox'
|
||||
className='h-10 w-20 rounded-[8px] border-border/50 p-0 transition-all duration-200 hover:border-border hover:bg-muted/50'
|
||||
>
|
||||
<SelectedIconComponent className='h-4 w-4' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='z-50 w-84 rounded-[8px] p-0' align='start'>
|
||||
<div className='p-3'>
|
||||
<div className='grid max-h-80 grid-cols-8 gap-2 overflow-y-auto'>
|
||||
{icons.map((icon) => {
|
||||
const IconComponent = icon.component
|
||||
return (
|
||||
<button
|
||||
key={icon.value}
|
||||
type='button'
|
||||
onClick={() => {
|
||||
field.onChange(icon.value)
|
||||
setIconPopoverOpen(false)
|
||||
}}
|
||||
className={cn(
|
||||
'flex h-8 w-8 items-center justify-center rounded-md border border-border/40 transition-all duration-200',
|
||||
'hover:scale-105 hover:border-border hover:bg-muted/50 active:scale-95',
|
||||
field.value === icon.value &&
|
||||
'border-primary/30 bg-primary/10 text-primary'
|
||||
)}
|
||||
>
|
||||
<IconComponent className='h-4 w-4' />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='color'
|
||||
render={({ field }) => (
|
||||
<FormItem className='w-20'>
|
||||
<FormLabel className='!text-foreground font-medium text-sm'>
|
||||
Color
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<ColorPicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
className='h-10 w-20 rounded-[8px]'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='!text-foreground font-medium text-sm'>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='Enter template name'
|
||||
className='h-10 rounded-[8px]'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='author'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='!text-foreground font-medium text-sm'>
|
||||
Author
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='Enter author name'
|
||||
className='h-10 rounded-[8px]'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='authorType'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='!text-foreground font-medium text-sm'>
|
||||
Author Type
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value)
|
||||
// Reset org selection when switching to user
|
||||
if (value === 'user') {
|
||||
form.setValue('organizationId', undefined)
|
||||
}
|
||||
}}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className='h-10 rounded-[8px]'>
|
||||
<SelectValue placeholder='Select author type' />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value='user'>User</SelectItem>
|
||||
<SelectItem value='organization'>Organization</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Organization selector - only show when authorType is 'organization' */}
|
||||
{authorType === 'organization' && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='organizationId'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='!text-foreground font-medium text-sm'>
|
||||
Organization
|
||||
</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger className='h-10 rounded-[8px]'>
|
||||
<SelectValue placeholder='Select an organization' />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{loadingOrgs ? (
|
||||
<SelectItem value='loading' disabled>
|
||||
Loading organizations...
|
||||
</SelectItem>
|
||||
) : organizations.length === 0 ? (
|
||||
<SelectItem value='none' disabled>
|
||||
No organizations available
|
||||
</SelectItem>
|
||||
) : (
|
||||
organizations.map((org) => (
|
||||
<SelectItem key={org.id} value={org.id}>
|
||||
{org.name}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='description'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='!text-foreground font-medium text-sm'>
|
||||
Description
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder='Describe what this template does...'
|
||||
className='min-h-[80px] resize-none rounded-[8px]'
|
||||
rows={3}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fixed Footer */}
|
||||
<div className='mt-auto border-t px-6 py-4'>
|
||||
<div className='flex items-center'>
|
||||
{existingTemplate && (
|
||||
<Button
|
||||
type='button'
|
||||
variant='destructive'
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
disabled={isSubmitting || isLoadingTemplate}
|
||||
className='h-9 rounded-[8px] px-4'
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type='submit'
|
||||
disabled={isSubmitting || !isFormValid || isLoadingTemplate}
|
||||
className={cn(
|
||||
'ml-auto h-9 rounded-[8px] px-4 font-[480]',
|
||||
'bg-[var(--brand-primary-hex)] hover:bg-[var(--brand-primary-hover-hex)]',
|
||||
'shadow-[0_0_0_0_var(--brand-primary-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]',
|
||||
'text-white transition-all duration-200',
|
||||
'disabled:opacity-50 disabled:hover:bg-[var(--brand-primary-hex)] disabled:hover:shadow-none'
|
||||
)}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
{existingTemplate ? 'Updating...' : 'Publishing...'}
|
||||
</>
|
||||
) : existingTemplate ? (
|
||||
'Update Template'
|
||||
) : (
|
||||
'Publish Template'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
{existingTemplate && (
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Template?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Deleting this template will remove it from the gallery. This action cannot be
|
||||
undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className='flex'>
|
||||
<AlertDialogCancel className='h-9 w-full rounded-[8px]' disabled={isDeleting}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className='h-9 w-full rounded-[8px] bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600'
|
||||
disabled={isDeleting}
|
||||
onClick={async () => {
|
||||
if (!existingTemplate) return
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
const resp = await fetch(`/api/templates/${existingTemplate.id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}))
|
||||
throw new Error(err.error || 'Failed to delete template')
|
||||
}
|
||||
setShowDeleteDialog(false)
|
||||
onOpenChange(false)
|
||||
} catch (err) {
|
||||
logger.error('Failed to delete template', err)
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -35,37 +35,64 @@ const NoteMarkdown = memo(function NoteMarkdown({ content }: { content: string }
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
p: ({ children }) => <p className='mb-0 text-[#E5E5E5] text-sm'>{children}</p>,
|
||||
h1: ({ children }) => (
|
||||
<h1 className='mt-0 mb-[-2px] font-semibold text-[#E5E5E5] text-lg'>{children}</h1>
|
||||
p: ({ children }: any) => (
|
||||
<p className='mb-2 break-words text-[#E5E5E5] text-sm'>{children}</p>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className='mt-0 mb-[-2px] font-semibold text-[#E5E5E5] text-base'>{children}</h2>
|
||||
h1: ({ children }: any) => (
|
||||
<h1 className='mt-3 mb-1 break-words font-semibold text-[#E5E5E5] text-lg first:mt-0'>
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className='mt-0 mb-[-2px] font-semibold text-[#E5E5E5] text-sm'>{children}</h3>
|
||||
h2: ({ children }: any) => (
|
||||
<h2 className='mt-3 mb-1 break-words font-semibold text-[#E5E5E5] text-base first:mt-0'>
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h4: ({ children }) => (
|
||||
<h4 className='mt-0 mb-[-2px] font-semibold text-[#E5E5E5] text-xs'>{children}</h4>
|
||||
h3: ({ children }: any) => (
|
||||
<h3 className='mt-3 mb-1 break-words font-semibold text-[#E5E5E5] text-sm first:mt-0'>
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<ul className='-mt-[2px] mb-0 list-disc pl-4 text-[#E5E5E5] text-sm'>{children}</ul>
|
||||
h4: ({ children }: any) => (
|
||||
<h4 className='mt-3 mb-1 break-words font-semibold text-[#E5E5E5] text-xs first:mt-0'>
|
||||
{children}
|
||||
</h4>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className='-mt-[2px] mb-0 list-decimal pl-4 text-[#E5E5E5] text-sm'>{children}</ol>
|
||||
ul: ({ children }: any) => (
|
||||
<ul className='mt-1 mb-2 list-disc break-words pl-4 text-[#E5E5E5] text-sm'>
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
li: ({ children }) => <li className='mb-0'>{children}</li>,
|
||||
code: ({ inline, children }: any) =>
|
||||
inline ? (
|
||||
<code className='rounded bg-[var(--divider)] px-1 py-0.5 text-[#F59E0B] text-xs'>
|
||||
ol: ({ children }: any) => (
|
||||
<ol className='mt-1 mb-2 list-decimal break-words pl-4 text-[#E5E5E5] text-sm'>
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({ children }: any) => <li className='mb-0 break-words'>{children}</li>,
|
||||
code: ({ inline, className, children, ...props }: any) => {
|
||||
const isInline = inline || !className?.includes('language-')
|
||||
|
||||
if (isInline) {
|
||||
return (
|
||||
<code
|
||||
{...props}
|
||||
className='whitespace-normal rounded bg-gray-200 px-1 py-0.5 font-mono text-[#F59E0B] text-xs dark:bg-[var(--surface-11)] dark:text-[#F59E0B]'
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<code
|
||||
{...props}
|
||||
className='block whitespace-pre-wrap break-words rounded bg-[#1A1A1A] p-2 text-[#E5E5E5] text-xs'
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
) : (
|
||||
<code className='block rounded bg-[#1A1A1A] p-2 text-[#E5E5E5] text-xs'>
|
||||
{children}
|
||||
</code>
|
||||
),
|
||||
a: ({ href, children }) => (
|
||||
)
|
||||
},
|
||||
a: ({ href, children }: any) => (
|
||||
<a
|
||||
href={href}
|
||||
target='_blank'
|
||||
@@ -75,10 +102,12 @@ const NoteMarkdown = memo(function NoteMarkdown({ content }: { content: string }
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
strong: ({ children }) => <strong className='font-semibold text-white'>{children}</strong>,
|
||||
em: ({ children }) => <em className='text-[#B8B8B8]'>{children}</em>,
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className='m-0 border-[#F59E0B] border-l-2 pl-3 text-[#B8B8B8] italic'>
|
||||
strong: ({ children }: any) => (
|
||||
<strong className='break-words font-semibold text-white'>{children}</strong>
|
||||
),
|
||||
em: ({ children }: any) => <em className='break-words text-[#B8B8B8]'>{children}</em>,
|
||||
blockquote: ({ children }: any) => (
|
||||
<blockquote className='mt-1 mb-2 break-words border-[#F59E0B] border-l-2 pl-3 text-[#B8B8B8] italic'>
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
@@ -181,15 +210,13 @@ export const NoteBlock = memo(function NoteBlock({ id, data }: NodeProps<NoteBlo
|
||||
</div>
|
||||
|
||||
<div className='relative px-[12px] pt-[6px] pb-[8px]'>
|
||||
<div className='relative whitespace-pre-wrap break-words'>
|
||||
<div className='relative break-words'>
|
||||
{isEmpty ? (
|
||||
<p className='text-[#868686] text-sm italic'>Add a note...</p>
|
||||
) : showMarkdown ? (
|
||||
<NoteMarkdown content={content} />
|
||||
) : (
|
||||
<p className='whitespace-pre-wrap text-[#E5E5E5] text-sm leading-relaxed'>
|
||||
{content}
|
||||
</p>
|
||||
<p className='whitespace-pre-wrap text-[#E5E5E5] text-sm leading-snug'>{content}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { shallow } from 'zustand/shallow'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
@@ -109,7 +110,11 @@ export function useMentionData(props: UseMentionDataProps) {
|
||||
const [workflowBlocks, setWorkflowBlocks] = useState<WorkflowBlockItem[]>([])
|
||||
const [isLoadingWorkflowBlocks, setIsLoadingWorkflowBlocks] = useState(false)
|
||||
|
||||
const workflowStoreBlocks = useWorkflowStore((state) => state.blocks)
|
||||
// Only subscribe to block keys to avoid re-rendering on position updates
|
||||
const blockKeys = useWorkflowStore(
|
||||
useCallback((state) => Object.keys(state.blocks), []),
|
||||
shallow
|
||||
)
|
||||
|
||||
// Use workflow registry as source of truth for workflows
|
||||
const registryWorkflows = useWorkflowRegistry((state) => state.workflows)
|
||||
@@ -139,15 +144,19 @@ export function useMentionData(props: UseMentionDataProps) {
|
||||
|
||||
/**
|
||||
* Syncs workflow blocks from store
|
||||
* Only re-runs when blocks are added/removed (not on position updates)
|
||||
*/
|
||||
useEffect(() => {
|
||||
const syncWorkflowBlocks = async () => {
|
||||
if (!workflowId || !workflowStoreBlocks || Object.keys(workflowStoreBlocks).length === 0) {
|
||||
if (!workflowId || blockKeys.length === 0) {
|
||||
setWorkflowBlocks([])
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch current blocks from store
|
||||
const workflowStoreBlocks = useWorkflowStore.getState().blocks
|
||||
|
||||
const { registry: blockRegistry } = await import('@/blocks/registry')
|
||||
const mapped = Object.values(workflowStoreBlocks).map((b: any) => {
|
||||
const reg = (blockRegistry as any)[b.type]
|
||||
@@ -169,7 +178,7 @@ export function useMentionData(props: UseMentionDataProps) {
|
||||
}
|
||||
|
||||
syncWorkflowBlocks()
|
||||
}, [workflowStoreBlocks, workflowId])
|
||||
}, [blockKeys, workflowId])
|
||||
|
||||
/**
|
||||
* Ensures past chats are loaded
|
||||
@@ -323,10 +332,10 @@ export function useMentionData(props: UseMentionDataProps) {
|
||||
if (!workflowId) return
|
||||
logger.debug('ensureWorkflowBlocksLoaded called', {
|
||||
workflowId,
|
||||
storeBlocksCount: Object.keys(workflowStoreBlocks || {}).length,
|
||||
storeBlocksCount: blockKeys.length,
|
||||
workflowBlocksCount: workflowBlocks.length,
|
||||
})
|
||||
}, [workflowId, workflowStoreBlocks, workflowBlocks.length])
|
||||
}, [workflowId, blockKeys.length, workflowBlocks.length])
|
||||
|
||||
return {
|
||||
// State
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Tooltip } from '@/components/emcn'
|
||||
import {
|
||||
type SlackChannelInfo,
|
||||
SlackChannelSelector,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/channel-selector/components/slack-channel-selector'
|
||||
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
|
||||
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-depends-on-gate'
|
||||
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-foreign-credential'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import type { SelectorContext } from '@/hooks/selectors/types'
|
||||
|
||||
interface ChannelSelectorInputProps {
|
||||
blockId: string
|
||||
@@ -41,14 +39,12 @@ export function ChannelSelectorInput({
|
||||
const effectiveAuthMethod = previewContextValues?.authMethod ?? authMethod
|
||||
const effectiveBotToken = previewContextValues?.botToken ?? botToken
|
||||
const effectiveCredential = previewContextValues?.credential ?? connectedCredential
|
||||
const [selectedChannelId, setSelectedChannelId] = useState<string>('')
|
||||
const [_channelInfo, setChannelInfo] = useState<SlackChannelInfo | null>(null)
|
||||
const [_channelInfo, setChannelInfo] = useState<string | null>(null)
|
||||
|
||||
// Get provider-specific values
|
||||
const provider = subBlock.provider || 'slack'
|
||||
const isSlack = provider === 'slack'
|
||||
// Central dependsOn gating
|
||||
const { finalDisabled, dependsOn, dependencyValues } = useDependsOnGate(blockId, subBlock, {
|
||||
const { finalDisabled, dependsOn } = useDependsOnGate(blockId, subBlock, {
|
||||
disabled,
|
||||
isPreview,
|
||||
previewContextValues,
|
||||
@@ -69,70 +65,60 @@ export function ChannelSelectorInput({
|
||||
// Get the current value from the store or prop value if in preview mode (same pattern as file-selector)
|
||||
useEffect(() => {
|
||||
const val = isPreview && previewValue !== undefined ? previewValue : storeValue
|
||||
if (val && typeof val === 'string') {
|
||||
setSelectedChannelId(val)
|
||||
if (typeof val === 'string') {
|
||||
setChannelInfo(val)
|
||||
}
|
||||
}, [isPreview, previewValue, storeValue])
|
||||
|
||||
// Clear channel when any declared dependency changes (e.g., authMethod/credential)
|
||||
const prevDepsSigRef = useRef<string>('')
|
||||
useEffect(() => {
|
||||
if (dependsOn.length === 0) return
|
||||
const currentSig = JSON.stringify(dependencyValues)
|
||||
if (prevDepsSigRef.current && prevDepsSigRef.current !== currentSig) {
|
||||
if (!isPreview) {
|
||||
setSelectedChannelId('')
|
||||
setChannelInfo(null)
|
||||
setStoreValue('')
|
||||
}
|
||||
}
|
||||
prevDepsSigRef.current = currentSig
|
||||
}, [dependsOn, dependencyValues, isPreview, setStoreValue])
|
||||
const requiresCredential = dependsOn.includes('credential')
|
||||
const missingCredential = !credential || credential.trim().length === 0
|
||||
const shouldForceDisable = requiresCredential && (missingCredential || isForeignCredential)
|
||||
|
||||
// Handle channel selection (same pattern as file-selector)
|
||||
const handleChannelChange = (channelId: string, info?: SlackChannelInfo) => {
|
||||
setSelectedChannelId(channelId)
|
||||
setChannelInfo(info || null)
|
||||
if (!isPreview) {
|
||||
setStoreValue(channelId)
|
||||
}
|
||||
onChannelSelect?.(channelId)
|
||||
}
|
||||
const context: SelectorContext = useMemo(
|
||||
() => ({
|
||||
credentialId: credential,
|
||||
workflowId: workflowIdFromUrl,
|
||||
}),
|
||||
[credential, workflowIdFromUrl]
|
||||
)
|
||||
|
||||
// Render Slack channel selector
|
||||
if (isSlack) {
|
||||
if (!isSlack) {
|
||||
return (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div className='w-full'>
|
||||
<SlackChannelSelector
|
||||
value={selectedChannelId}
|
||||
onChange={(channelId: string, channelInfo?: SlackChannelInfo) => {
|
||||
handleChannelChange(channelId, channelInfo)
|
||||
}}
|
||||
credential={credential}
|
||||
label={subBlock.placeholder || 'Select Slack channel'}
|
||||
disabled={finalDisabled}
|
||||
workflowId={workflowIdFromUrl}
|
||||
isForeignCredential={isForeignCredential}
|
||||
/>
|
||||
<div className='w-full rounded border border-dashed p-4 text-center text-muted-foreground text-sm'>
|
||||
Channel selector not supported for provider: {provider}
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
<p>This channel selector is not yet implemented for {provider}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)
|
||||
}
|
||||
|
||||
// Default fallback for unsupported providers
|
||||
return (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div className='w-full rounded border border-dashed p-4 text-center text-muted-foreground text-sm'>
|
||||
Channel selector not supported for provider: {provider}
|
||||
<div className='w-full'>
|
||||
<SelectorCombobox
|
||||
blockId={blockId}
|
||||
subBlock={subBlock}
|
||||
selectorKey='slack.channels'
|
||||
selectorContext={context}
|
||||
disabled={finalDisabled || shouldForceDisable || isForeignCredential}
|
||||
isPreview={isPreview}
|
||||
previewValue={previewValue ?? null}
|
||||
placeholder={subBlock.placeholder || 'Select Slack channel'}
|
||||
onOptionChange={(value) => {
|
||||
setChannelInfo(value)
|
||||
if (!isPreview) {
|
||||
onChannelSelect?.(value)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
<p>This channel selector is not yet implemented for {provider}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { Check, ChevronDown, Hash, Lock, RefreshCw } from 'lucide-react'
|
||||
import { SlackIcon } from '@/components/icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { useDisplayNamesStore } from '@/stores/display-names/store'
|
||||
|
||||
export interface SlackChannelInfo {
|
||||
id: string
|
||||
name: string
|
||||
isPrivate: boolean
|
||||
}
|
||||
|
||||
interface SlackChannelSelectorProps {
|
||||
value: string
|
||||
onChange: (channelId: string, channelInfo?: SlackChannelInfo) => void
|
||||
credential: string
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
workflowId?: string
|
||||
isForeignCredential?: boolean
|
||||
}
|
||||
|
||||
export function SlackChannelSelector({
|
||||
value,
|
||||
onChange,
|
||||
credential,
|
||||
label = 'Select Slack channel',
|
||||
disabled = false,
|
||||
workflowId,
|
||||
isForeignCredential = false,
|
||||
}: SlackChannelSelectorProps) {
|
||||
const [channels, setChannels] = useState<SlackChannelInfo[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [initialFetchDone, setInitialFetchDone] = useState(false)
|
||||
|
||||
// Get cached display name
|
||||
const cachedChannelName = useDisplayNamesStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (!credential || !value) return null
|
||||
return state.cache.channels[credential]?.[value] || null
|
||||
},
|
||||
[credential, value]
|
||||
)
|
||||
)
|
||||
|
||||
// Fetch channels from Slack API
|
||||
const fetchChannels = useCallback(async () => {
|
||||
if (!credential) return
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/tools/slack/channels', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ credential, workflowId }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const errorData = await res
|
||||
.json()
|
||||
.catch(() => ({ error: `HTTP error! status: ${res.status}` }))
|
||||
setError(errorData.error || `HTTP error! status: ${res.status}`)
|
||||
setChannels([])
|
||||
setInitialFetchDone(true)
|
||||
return
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
if (data.error) {
|
||||
setError(data.error)
|
||||
setChannels([])
|
||||
setInitialFetchDone(true)
|
||||
} else {
|
||||
setChannels(data.channels)
|
||||
setInitialFetchDone(true)
|
||||
|
||||
// Cache channel names in display names store
|
||||
if (credential) {
|
||||
const channelMap = data.channels.reduce(
|
||||
(acc: Record<string, string>, ch: SlackChannelInfo) => {
|
||||
acc[ch.id] = `#${ch.name}`
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
useDisplayNamesStore.getState().setDisplayNames('channels', credential, channelMap)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as Error).name === 'AbortError') return
|
||||
setError((err as Error).message)
|
||||
setChannels([])
|
||||
setInitialFetchDone(true)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [credential])
|
||||
|
||||
// Handle dropdown open/close - fetch channels when opening
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
setOpen(isOpen)
|
||||
|
||||
// Only fetch channels when opening the dropdown and if we have valid credential
|
||||
if (isOpen && credential && (!initialFetchDone || channels.length === 0)) {
|
||||
fetchChannels()
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectChannel = (channel: SlackChannelInfo) => {
|
||||
onChange(channel.id, channel)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const getChannelIcon = (channel: SlackChannelInfo) => {
|
||||
return channel.isPrivate ? <Lock className='h-1.5 w-1.5' /> : <Hash className='h-1.5 w-1.5' />
|
||||
}
|
||||
|
||||
const formatChannelName = (channel: SlackChannelInfo) => {
|
||||
return channel.name
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
role='combobox'
|
||||
aria-expanded={open}
|
||||
className='relative w-full justify-between'
|
||||
disabled={disabled || !credential}
|
||||
title={isForeignCredential ? 'Using a shared account' : undefined}
|
||||
>
|
||||
<div className='flex max-w-[calc(100%-20px)] items-center gap-2 overflow-hidden'>
|
||||
<SlackIcon className='h-4 w-4 text-[#611f69]' />
|
||||
{cachedChannelName ? (
|
||||
<span className='truncate font-normal'>{cachedChannelName}</span>
|
||||
) : (
|
||||
<span className='truncate text-muted-foreground'>{label}</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown className='absolute right-3 h-4 w-4 shrink-0 opacity-50' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-[250px] p-0' align='start'>
|
||||
<Command>
|
||||
<CommandInput placeholder='Search channels...' />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{loading ? (
|
||||
<div className='flex items-center justify-center p-4'>
|
||||
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||
<span className='ml-2'>Loading channels...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='text-destructive text-sm'>{error}</p>
|
||||
</div>
|
||||
) : !credential ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>Missing credentials</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Please configure Slack credentials.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No channels found</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
No channels available for this Slack workspace.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CommandEmpty>
|
||||
|
||||
{channels.length > 0 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
Channels
|
||||
</div>
|
||||
{channels.map((channel) => (
|
||||
<CommandItem
|
||||
key={channel.id}
|
||||
value={`channel-${channel.id}-${channel.name}`}
|
||||
onSelect={() => handleSelectChannel(channel)}
|
||||
className='cursor-pointer'
|
||||
>
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
<SlackIcon className='h-4 w-4 text-[#611f69]' />
|
||||
{getChannelIcon(channel)}
|
||||
<span className='truncate font-normal'>{formatChannelName(channel)}</span>
|
||||
{channel.isPrivate && (
|
||||
<span className='ml-auto text-muted-foreground text-xs'>Private</span>
|
||||
)}
|
||||
</div>
|
||||
{channel.id === value && <Check className='ml-auto h-4 w-4' />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import { WandPromptBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/comp
|
||||
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
|
||||
import { useWand } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand'
|
||||
import type { GenerationType } from '@/blocks/types'
|
||||
import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation'
|
||||
import { useTagSelection } from '@/hooks/use-tag-selection'
|
||||
import { normalizeBlockName } from '@/stores/workflows/utils'
|
||||
|
||||
@@ -99,14 +100,15 @@ const createHighlightFunction = (
|
||||
let processedCode = codeToHighlight
|
||||
|
||||
// Replace environment variables with placeholders
|
||||
processedCode = processedCode.replace(/\{\{([^}]+)\}\}/g, (match) => {
|
||||
processedCode = processedCode.replace(createEnvVarPattern(), (match) => {
|
||||
const placeholder = `__ENV_VAR_${placeholders.length}__`
|
||||
placeholders.push({ placeholder, original: match, type: 'env' })
|
||||
return placeholder
|
||||
})
|
||||
|
||||
// Replace variable references with placeholders
|
||||
processedCode = processedCode.replace(/<([^>]+)>/g, (match) => {
|
||||
// Use [^<>]+ to prevent matching across nested brackets (e.g., "<3 <real.ref>" should match separately)
|
||||
processedCode = processedCode.replace(createReferencePattern(), (match) => {
|
||||
if (shouldHighlightReference(match)) {
|
||||
const placeholder = `__VAR_REF_${placeholders.length}__`
|
||||
placeholders.push({ placeholder, original: match, type: 'var' })
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
|
||||
import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation'
|
||||
import { useTagSelection } from '@/hooks/use-tag-selection'
|
||||
import { normalizeBlockName } from '@/stores/workflows/utils'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
@@ -864,25 +865,41 @@ export function ConditionInput({
|
||||
placeholder: string
|
||||
original: string
|
||||
type: 'var' | 'env'
|
||||
shouldHighlight: boolean
|
||||
}[] = []
|
||||
let processedCode = codeToHighlight
|
||||
|
||||
// Replace environment variables with placeholders
|
||||
processedCode = processedCode.replace(/\{\{([^}]+)\}\}/g, (match) => {
|
||||
processedCode = processedCode.replace(createEnvVarPattern(), (match) => {
|
||||
const placeholder = `__ENV_VAR_${placeholders.length}__`
|
||||
placeholders.push({ placeholder, original: match, type: 'env' })
|
||||
placeholders.push({
|
||||
placeholder,
|
||||
original: match,
|
||||
type: 'env',
|
||||
shouldHighlight: true,
|
||||
})
|
||||
return placeholder
|
||||
})
|
||||
|
||||
// Replace variable references with placeholders
|
||||
processedCode = processedCode.replace(/<([^>]+)>/g, (match) => {
|
||||
if (shouldHighlightReference(match)) {
|
||||
const placeholder = `__VAR_REF_${placeholders.length}__`
|
||||
placeholders.push({ placeholder, original: match, type: 'var' })
|
||||
return placeholder
|
||||
// Use [^<>]+ to prevent matching across nested brackets (e.g., "<3 <real.ref>" should match separately)
|
||||
processedCode = processedCode.replace(
|
||||
createReferencePattern(),
|
||||
(match) => {
|
||||
const shouldHighlight = shouldHighlightReference(match)
|
||||
if (shouldHighlight) {
|
||||
const placeholder = `__VAR_REF_${placeholders.length}__`
|
||||
placeholders.push({
|
||||
placeholder,
|
||||
original: match,
|
||||
type: 'var',
|
||||
shouldHighlight: true,
|
||||
})
|
||||
return placeholder
|
||||
}
|
||||
return match
|
||||
}
|
||||
return match
|
||||
})
|
||||
)
|
||||
|
||||
// Apply Prism syntax highlighting
|
||||
let highlightedCode = highlight(
|
||||
@@ -892,21 +909,25 @@ export function ConditionInput({
|
||||
)
|
||||
|
||||
// Restore and highlight the placeholders
|
||||
placeholders.forEach(({ placeholder, original, type }) => {
|
||||
if (type === 'env') {
|
||||
highlightedCode = highlightedCode.replace(
|
||||
placeholder,
|
||||
`<span class="text-blue-500">${original}</span>`
|
||||
)
|
||||
} else if (type === 'var') {
|
||||
// Escape the < and > for display
|
||||
const escaped = original.replace(/</g, '<').replace(/>/g, '>')
|
||||
highlightedCode = highlightedCode.replace(
|
||||
placeholder,
|
||||
`<span class="text-blue-500">${escaped}</span>`
|
||||
)
|
||||
placeholders.forEach(
|
||||
({ placeholder, original, type, shouldHighlight }) => {
|
||||
if (!shouldHighlight) return
|
||||
|
||||
if (type === 'env') {
|
||||
highlightedCode = highlightedCode.replace(
|
||||
placeholder,
|
||||
`<span class="text-blue-500">${original}</span>`
|
||||
)
|
||||
} else if (type === 'var') {
|
||||
// Escape the < and > for display
|
||||
const escaped = original.replace(/</g, '<').replace(/>/g, '>')
|
||||
highlightedCode = highlightedCode.replace(
|
||||
placeholder,
|
||||
`<span class="text-blue-500">${escaped}</span>`
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return highlightedCode
|
||||
}}
|
||||
|
||||
@@ -1,20 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Check, ChevronDown, ExternalLink, RefreshCw } from 'lucide-react'
|
||||
import { Button } from '@/components/emcn/components/button/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import { Button, Combobox } from '@/components/emcn/components'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
type Credential,
|
||||
getCanonicalScopesForProvider,
|
||||
getProviderIdFromServiceId,
|
||||
getServiceIdFromScopes,
|
||||
@@ -25,9 +15,8 @@ import {
|
||||
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useOAuthCredentialDetail, useOAuthCredentials } from '@/hooks/queries/oauth-credentials'
|
||||
import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status'
|
||||
import { useDisplayNamesStore } from '@/stores/display-names/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
const logger = createLogger('CredentialSelector')
|
||||
@@ -47,262 +36,133 @@ export function CredentialSelector({
|
||||
isPreview = false,
|
||||
previewValue,
|
||||
}: CredentialSelectorProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [credentials, setCredentials] = useState<Credential[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [showOAuthModal, setShowOAuthModal] = useState(false)
|
||||
const [selectedId, setSelectedId] = useState('')
|
||||
const [hasForeignMeta, setHasForeignMeta] = useState(false)
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
|
||||
const [storeValue, setStoreValue] = useSubBlockValue<string | null>(blockId, subBlock.id)
|
||||
|
||||
// Use collaborative state management via useSubBlockValue hook
|
||||
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
|
||||
|
||||
// Extract values from subBlock config
|
||||
const provider = subBlock.provider as OAuthProvider
|
||||
const requiredScopes = subBlock.requiredScopes || []
|
||||
const label = subBlock.placeholder || 'Select credential'
|
||||
const serviceId = subBlock.serviceId
|
||||
|
||||
// Get the effective value (preview or store value)
|
||||
const effectiveValue = isPreview && previewValue !== undefined ? previewValue : storeValue
|
||||
const selectedId = typeof effectiveValue === 'string' ? effectiveValue : ''
|
||||
|
||||
const effectiveServiceId = useMemo(
|
||||
() => serviceId || getServiceIdFromScopes(provider, requiredScopes),
|
||||
[provider, requiredScopes, serviceId]
|
||||
)
|
||||
|
||||
const effectiveProviderId = useMemo(
|
||||
() => getProviderIdFromServiceId(effectiveServiceId),
|
||||
[effectiveServiceId]
|
||||
)
|
||||
|
||||
const {
|
||||
data: credentials = [],
|
||||
isFetching: credentialsLoading,
|
||||
refetch: refetchCredentials,
|
||||
} = useOAuthCredentials(effectiveProviderId, Boolean(effectiveProviderId))
|
||||
|
||||
const selectedCredential = useMemo(
|
||||
() => credentials.find((cred) => cred.id === selectedId),
|
||||
[credentials, selectedId]
|
||||
)
|
||||
|
||||
const shouldFetchForeignMeta =
|
||||
Boolean(selectedId) &&
|
||||
!selectedCredential &&
|
||||
Boolean(activeWorkflowId) &&
|
||||
Boolean(effectiveProviderId)
|
||||
|
||||
const { data: foreignCredentials = [], isFetching: foreignMetaLoading } =
|
||||
useOAuthCredentialDetail(
|
||||
shouldFetchForeignMeta ? selectedId : undefined,
|
||||
activeWorkflowId || undefined,
|
||||
shouldFetchForeignMeta
|
||||
)
|
||||
|
||||
const hasForeignMeta = foreignCredentials.length > 0
|
||||
const isForeign = Boolean(selectedId && !selectedCredential && hasForeignMeta)
|
||||
|
||||
const resolvedLabel = useMemo(() => {
|
||||
if (selectedCredential) return selectedCredential.name
|
||||
if (isForeign) return 'Saved by collaborator'
|
||||
return ''
|
||||
}, [selectedCredential, isForeign])
|
||||
|
||||
// Initialize selectedId with the effective value
|
||||
useEffect(() => {
|
||||
setSelectedId(effectiveValue || '')
|
||||
}, [effectiveValue])
|
||||
if (!isEditing) {
|
||||
setInputValue(resolvedLabel)
|
||||
}
|
||||
}, [resolvedLabel, isEditing])
|
||||
|
||||
// Derive service and provider IDs using useMemo
|
||||
const effectiveServiceId = useMemo(() => {
|
||||
return serviceId || getServiceIdFromScopes(provider, requiredScopes)
|
||||
}, [provider, requiredScopes, serviceId])
|
||||
const invalidSelection =
|
||||
!isPreview &&
|
||||
Boolean(selectedId) &&
|
||||
!selectedCredential &&
|
||||
!hasForeignMeta &&
|
||||
!credentialsLoading &&
|
||||
!foreignMetaLoading
|
||||
|
||||
const effectiveProviderId = useMemo(() => {
|
||||
return getProviderIdFromServiceId(effectiveServiceId)
|
||||
}, [effectiveServiceId])
|
||||
useEffect(() => {
|
||||
if (!invalidSelection) return
|
||||
logger.info('Clearing invalid credential selection - credential was disconnected', {
|
||||
selectedId,
|
||||
provider: effectiveProviderId,
|
||||
})
|
||||
setStoreValue('')
|
||||
}, [invalidSelection, selectedId, effectiveProviderId, setStoreValue])
|
||||
|
||||
// Fetch available credentials for this provider
|
||||
const fetchCredentials = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const response = await fetch(`/api/auth/oauth/credentials?provider=${effectiveProviderId}`)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
const creds = data.credentials as Credential[]
|
||||
let foreignMetaFound = false
|
||||
useCredentialRefreshTriggers(refetchCredentials, effectiveProviderId, provider)
|
||||
|
||||
// If persisted selection is not among viewer's credentials, attempt to fetch its metadata
|
||||
if (
|
||||
selectedId &&
|
||||
!(creds || []).some((cred: Credential) => cred.id === selectedId) &&
|
||||
activeWorkflowId
|
||||
) {
|
||||
try {
|
||||
const metaResp = await fetch(
|
||||
`/api/auth/oauth/credentials?credentialId=${selectedId}&workflowId=${activeWorkflowId}`
|
||||
)
|
||||
if (metaResp.ok) {
|
||||
const meta = await metaResp.json()
|
||||
if (meta.credentials?.length) {
|
||||
// Mark as foreign, but do NOT merge into list to avoid leaking owner email
|
||||
foreignMetaFound = true
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore meta errors
|
||||
}
|
||||
}
|
||||
|
||||
setHasForeignMeta(foreignMetaFound)
|
||||
setCredentials(creds)
|
||||
|
||||
// Cache credential names in display names store
|
||||
if (effectiveProviderId) {
|
||||
const credentialMap = creds.reduce((acc: Record<string, string>, cred: Credential) => {
|
||||
acc[cred.id] = cred.name
|
||||
return acc
|
||||
}, {})
|
||||
useDisplayNamesStore
|
||||
.getState()
|
||||
.setDisplayNames('credentials', effectiveProviderId, credentialMap)
|
||||
}
|
||||
|
||||
// Check if the currently selected credential still exists
|
||||
const selectedCredentialStillExists = (creds || []).some(
|
||||
(cred: Credential) => cred.id === selectedId
|
||||
)
|
||||
const shouldClearPersistedSelection =
|
||||
!isPreview && selectedId && !selectedCredentialStillExists && !foreignMetaFound
|
||||
|
||||
if (shouldClearPersistedSelection) {
|
||||
logger.info('Clearing invalid credential selection - credential was disconnected', {
|
||||
selectedId,
|
||||
provider: effectiveProviderId,
|
||||
})
|
||||
|
||||
// Clear via setStoreValue to trigger cascade
|
||||
setStoreValue('')
|
||||
setSelectedId('')
|
||||
|
||||
if (effectiveProviderId) {
|
||||
useDisplayNamesStore
|
||||
.getState()
|
||||
.removeDisplayName('credentials', effectiveProviderId, selectedId)
|
||||
}
|
||||
}
|
||||
const handleOpenChange = useCallback(
|
||||
(isOpen: boolean) => {
|
||||
if (isOpen) {
|
||||
void refetchCredentials()
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching credentials:', { error })
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [effectiveProviderId, selectedId, activeWorkflowId, isPreview, setStoreValue])
|
||||
},
|
||||
[refetchCredentials]
|
||||
)
|
||||
|
||||
// Fetch credentials on initial mount and whenever the subblock value changes externally
|
||||
useEffect(() => {
|
||||
fetchCredentials()
|
||||
}, [fetchCredentials, effectiveValue])
|
||||
|
||||
// When the selectedId changes (e.g., collaborator saved a credential), determine if it's foreign
|
||||
useEffect(() => {
|
||||
let aborted = false
|
||||
;(async () => {
|
||||
try {
|
||||
if (!selectedId) {
|
||||
setHasForeignMeta(false)
|
||||
return
|
||||
}
|
||||
// If the selected credential exists in viewer's list, it's not foreign
|
||||
if ((credentials || []).some((cred) => cred.id === selectedId)) {
|
||||
setHasForeignMeta(false)
|
||||
return
|
||||
}
|
||||
if (!activeWorkflowId) return
|
||||
const metaResp = await fetch(
|
||||
`/api/auth/oauth/credentials?credentialId=${selectedId}&workflowId=${activeWorkflowId}`
|
||||
)
|
||||
if (aborted) return
|
||||
if (metaResp.ok) {
|
||||
const meta = await metaResp.json()
|
||||
setHasForeignMeta(!!meta.credentials?.length)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
aborted = true
|
||||
}
|
||||
}, [selectedId, credentials, activeWorkflowId])
|
||||
|
||||
// This effect is no longer needed since we're using effectiveValue directly
|
||||
|
||||
// Listen for visibility changes to update credentials when user returns from settings
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
fetchCredentials()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
}
|
||||
}, [fetchCredentials])
|
||||
|
||||
// Also handle BFCache restores (back/forward navigation) where visibility change may not fire reliably
|
||||
useEffect(() => {
|
||||
const handlePageShow = (event: any) => {
|
||||
if (event?.persisted) {
|
||||
fetchCredentials()
|
||||
}
|
||||
}
|
||||
window.addEventListener('pageshow', handlePageShow)
|
||||
return () => {
|
||||
window.removeEventListener('pageshow', handlePageShow)
|
||||
}
|
||||
}, [fetchCredentials])
|
||||
|
||||
// Listen for credential disconnection events from settings modal
|
||||
useEffect(() => {
|
||||
const handleCredentialDisconnected = (event: Event) => {
|
||||
const customEvent = event as CustomEvent
|
||||
const { providerId } = customEvent.detail
|
||||
// Re-fetch if this disconnection affects our provider
|
||||
if (providerId && (providerId === effectiveProviderId || providerId.startsWith(provider))) {
|
||||
fetchCredentials()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('credential-disconnected', handleCredentialDisconnected)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('credential-disconnected', handleCredentialDisconnected)
|
||||
}
|
||||
}, [fetchCredentials, effectiveProviderId, provider])
|
||||
|
||||
// Handle popover open to fetch fresh credentials
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
setOpen(isOpen)
|
||||
if (isOpen) {
|
||||
// Fetch fresh credentials when opening the dropdown
|
||||
fetchCredentials()
|
||||
}
|
||||
}
|
||||
|
||||
// Get the selected credential
|
||||
const selectedCredential = credentials.find((cred) => cred.id === selectedId)
|
||||
const isForeign = !!(selectedId && !selectedCredential && hasForeignMeta)
|
||||
|
||||
// If the list doesn’t contain the effective value but meta says it exists, synthesize a non-leaky placeholder to render stable UI
|
||||
const displayName = selectedCredential
|
||||
? selectedCredential.name
|
||||
: isForeign
|
||||
? 'Saved by collaborator'
|
||||
: undefined
|
||||
|
||||
// Determine if additional permissions are required for the selected credential
|
||||
const hasSelection = !!selectedCredential
|
||||
const hasSelection = Boolean(selectedCredential)
|
||||
const missingRequiredScopes = hasSelection
|
||||
? getMissingRequiredScopes(selectedCredential, requiredScopes || [])
|
||||
? getMissingRequiredScopes(selectedCredential!, requiredScopes || [])
|
||||
: []
|
||||
|
||||
const needsUpdate =
|
||||
hasSelection && missingRequiredScopes.length > 0 && !disabled && !isPreview && !isLoading
|
||||
hasSelection &&
|
||||
missingRequiredScopes.length > 0 &&
|
||||
!disabled &&
|
||||
!isPreview &&
|
||||
!credentialsLoading
|
||||
|
||||
// Handle selection
|
||||
const handleSelect = (credentialId: string) => {
|
||||
const previousId = selectedId || (effectiveValue as string) || ''
|
||||
setSelectedId(credentialId)
|
||||
if (!isPreview) {
|
||||
const handleSelect = useCallback(
|
||||
(credentialId: string) => {
|
||||
if (isPreview) return
|
||||
setStoreValue(credentialId)
|
||||
}
|
||||
setOpen(false)
|
||||
}
|
||||
setIsEditing(false)
|
||||
},
|
||||
[isPreview, setStoreValue]
|
||||
)
|
||||
|
||||
// Handle adding a new credential
|
||||
const handleAddCredential = () => {
|
||||
// Show the OAuth modal
|
||||
const handleAddCredential = useCallback(() => {
|
||||
setShowOAuthModal(true)
|
||||
setOpen(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Get provider icon
|
||||
const getProviderIcon = (providerName: OAuthProvider) => {
|
||||
const getProviderIcon = useCallback((providerName: OAuthProvider) => {
|
||||
const { baseProvider } = parseProvider(providerName)
|
||||
const baseProviderConfig = OAUTH_PROVIDERS[baseProvider]
|
||||
|
||||
if (!baseProviderConfig) {
|
||||
return <ExternalLink className='h-4 w-4' />
|
||||
return <ExternalLink className='h-3 w-3' />
|
||||
}
|
||||
// Always use the base provider icon for a more consistent UI
|
||||
return baseProviderConfig.icon({ className: 'h-4 w-4' })
|
||||
}
|
||||
return baseProviderConfig.icon({ className: 'h-3 w-3' })
|
||||
}, [])
|
||||
|
||||
// Get provider name
|
||||
const getProviderName = (providerName: OAuthProvider) => {
|
||||
const getProviderName = useCallback((providerName: OAuthProvider) => {
|
||||
const { baseProvider } = parseProvider(providerName)
|
||||
const baseProviderConfig = OAUTH_PROVIDERS[baseProvider]
|
||||
|
||||
@@ -310,88 +170,79 @@ export function CredentialSelector({
|
||||
return baseProviderConfig.name
|
||||
}
|
||||
|
||||
// Fallback: capitalize the provider name
|
||||
return providerName
|
||||
.split('-')
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const comboboxOptions = useMemo(() => {
|
||||
const options = credentials.map((cred) => ({
|
||||
label: cred.name,
|
||||
value: cred.id,
|
||||
}))
|
||||
|
||||
if (credentials.length === 0) {
|
||||
options.push({
|
||||
label: `Connect ${getProviderName(provider)} account`,
|
||||
value: '__connect_account__',
|
||||
})
|
||||
}
|
||||
|
||||
return options
|
||||
}, [credentials, provider, getProviderName])
|
||||
|
||||
const selectedCredentialProvider = selectedCredential?.provider ?? provider
|
||||
|
||||
const overlayContent = useMemo(() => {
|
||||
if (!inputValue) return null
|
||||
|
||||
return (
|
||||
<div className='flex w-full items-center truncate'>
|
||||
<div className='mr-2 flex-shrink-0 opacity-90'>
|
||||
{getProviderIcon(selectedCredentialProvider)}
|
||||
</div>
|
||||
<span className='truncate'>{inputValue}</span>
|
||||
</div>
|
||||
)
|
||||
}, [getProviderIcon, inputValue, selectedCredentialProvider])
|
||||
|
||||
const handleComboboxChange = useCallback(
|
||||
(value: string) => {
|
||||
if (value === '__connect_account__') {
|
||||
handleAddCredential()
|
||||
return
|
||||
}
|
||||
|
||||
const matchedCred = credentials.find((c) => c.id === value)
|
||||
if (matchedCred) {
|
||||
setInputValue(matchedCred.name)
|
||||
handleSelect(value)
|
||||
return
|
||||
}
|
||||
|
||||
setIsEditing(true)
|
||||
setInputValue(value)
|
||||
},
|
||||
[credentials, handleAddCredential, handleSelect]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
role='combobox'
|
||||
aria-expanded={open}
|
||||
className='relative w-full justify-between'
|
||||
disabled={disabled}
|
||||
>
|
||||
<div className='flex max-w-[calc(100%-20px)] items-center gap-2 overflow-hidden'>
|
||||
{getProviderIcon(provider)}
|
||||
<span
|
||||
className={displayName ? 'truncate font-normal' : 'truncate text-muted-foreground'}
|
||||
>
|
||||
{displayName || label}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown className='absolute right-3 h-4 w-4 shrink-0 opacity-50' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-[250px] p-0' align='start'>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder='Search credentials...'
|
||||
className='text-foreground placeholder:text-muted-foreground'
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{isLoading ? (
|
||||
<div className='flex items-center justify-center p-4'>
|
||||
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||
<span className='ml-2'>Loading credentials...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No credentials found.</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Connect a new account to continue.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CommandEmpty>
|
||||
{credentials.length > 0 && (
|
||||
<CommandGroup>
|
||||
{credentials.map((cred) => (
|
||||
<CommandItem
|
||||
key={cred.id}
|
||||
value={cred.id}
|
||||
onSelect={() => handleSelect(cred.id)}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
{getProviderIcon(cred.provider)}
|
||||
<span className='font-normal'>{cred.name}</span>
|
||||
</div>
|
||||
{cred.id === selectedId && <Check className='ml-auto h-4 w-4' />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{credentials.length === 0 && (
|
||||
<CommandGroup>
|
||||
<CommandItem onSelect={handleAddCredential}>
|
||||
<div className='flex items-center gap-2 text-foreground'>
|
||||
{getProviderIcon(provider)}
|
||||
<span>Connect {getProviderName(provider)} account</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Combobox
|
||||
options={comboboxOptions}
|
||||
value={inputValue}
|
||||
selectedValue={selectedId}
|
||||
onChange={handleComboboxChange}
|
||||
onOpenChange={handleOpenChange}
|
||||
placeholder={label}
|
||||
disabled={disabled}
|
||||
editable={true}
|
||||
filterOptions={true}
|
||||
isLoading={credentialsLoading}
|
||||
overlayContent={overlayContent}
|
||||
className={selectedId ? 'pl-[28px]' : ''}
|
||||
/>
|
||||
|
||||
{needsUpdate && (
|
||||
<div className='mt-2 flex items-center justify-between rounded-[6px] border border-amber-300/40 bg-amber-50/60 px-2 py-1 font-medium text-[12px] transition-colors dark:bg-amber-950/10'>
|
||||
@@ -414,3 +265,49 @@ export function CredentialSelector({
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function useCredentialRefreshTriggers(
|
||||
refetchCredentials: () => Promise<unknown>,
|
||||
effectiveProviderId?: string,
|
||||
provider?: OAuthProvider
|
||||
) {
|
||||
useEffect(() => {
|
||||
const refresh = () => {
|
||||
void refetchCredentials()
|
||||
}
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
const handlePageShow = (event: Event) => {
|
||||
if ('persisted' in event && (event as PageTransitionEvent).persisted) {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
const handleCredentialDisconnected = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<{ providerId?: string }>
|
||||
const providerId = customEvent.detail?.providerId
|
||||
|
||||
if (
|
||||
providerId &&
|
||||
(providerId === effectiveProviderId || (provider && providerId.startsWith(provider)))
|
||||
) {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
window.addEventListener('pageshow', handlePageShow)
|
||||
window.addEventListener('credential-disconnected', handleCredentialDisconnected)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
window.removeEventListener('pageshow', handlePageShow)
|
||||
window.removeEventListener('credential-disconnected', handleCredentialDisconnected)
|
||||
}
|
||||
}, [refetchCredentials, effectiveProviderId, provider])
|
||||
}
|
||||
|
||||
@@ -1,23 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Check, ChevronDown, FileText, RefreshCw } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { Tooltip } from '@/components/emcn'
|
||||
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
|
||||
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-depends-on-gate'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { useKnowledgeBaseDocuments } from '@/hooks/use-knowledge'
|
||||
import { useDisplayNamesStore } from '@/stores/display-names/store'
|
||||
import type { DocumentData } from '@/stores/knowledge/store'
|
||||
import type { SelectorContext } from '@/hooks/selectors/types'
|
||||
|
||||
interface DocumentSelectorProps {
|
||||
blockId: string
|
||||
@@ -36,186 +25,54 @@ export function DocumentSelector({
|
||||
isPreview = false,
|
||||
previewValue,
|
||||
}: DocumentSelectorProps) {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
|
||||
const [knowledgeBaseId] = useSubBlockValue(blockId, 'knowledgeBaseId')
|
||||
const { finalDisabled } = useDependsOnGate(blockId, subBlock, { disabled, isPreview })
|
||||
const [knowledgeBaseIdValue] = useSubBlockValue(blockId, 'knowledgeBaseId')
|
||||
const normalizedKnowledgeBaseId =
|
||||
typeof knowledgeBaseId === 'string' && knowledgeBaseId.trim().length > 0
|
||||
? knowledgeBaseId
|
||||
typeof knowledgeBaseIdValue === 'string' && knowledgeBaseIdValue.trim().length > 0
|
||||
? knowledgeBaseIdValue
|
||||
: null
|
||||
|
||||
const value = isPreview ? previewValue : storeValue
|
||||
|
||||
const { finalDisabled } = useDependsOnGate(blockId, subBlock, { disabled, isPreview })
|
||||
const isDisabled = finalDisabled
|
||||
|
||||
const {
|
||||
documents,
|
||||
isLoading: documentsLoading,
|
||||
error: documentsError,
|
||||
refreshDocuments,
|
||||
} = useKnowledgeBaseDocuments(normalizedKnowledgeBaseId ?? '', {
|
||||
limit: 500,
|
||||
offset: 0,
|
||||
enabled: open && Boolean(normalizedKnowledgeBaseId),
|
||||
})
|
||||
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
if (isPreview || isDisabled) return
|
||||
|
||||
setOpen(isOpen)
|
||||
|
||||
if (isOpen && normalizedKnowledgeBaseId) {
|
||||
void refreshDocuments()
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectDocument = (document: DocumentData) => {
|
||||
if (isPreview) return
|
||||
|
||||
setStoreValue(document.id)
|
||||
onDocumentSelect?.(document.id)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!normalizedKnowledgeBaseId) {
|
||||
setError(null)
|
||||
}
|
||||
}, [normalizedKnowledgeBaseId])
|
||||
|
||||
useEffect(() => {
|
||||
setError(documentsError)
|
||||
}, [documentsError])
|
||||
|
||||
useEffect(() => {
|
||||
if (!normalizedKnowledgeBaseId || documents.length === 0) return
|
||||
|
||||
const documentMap = documents.reduce<Record<string, string>>((acc, doc) => {
|
||||
acc[doc.id] = doc.filename
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
useDisplayNamesStore
|
||||
.getState()
|
||||
.setDisplayNames('documents', normalizedKnowledgeBaseId as string, documentMap)
|
||||
}, [documents, normalizedKnowledgeBaseId])
|
||||
|
||||
const formatDocumentName = (document: DocumentData) => document.filename
|
||||
|
||||
const getDocumentDescription = (document: DocumentData) => {
|
||||
const statusMap: Record<string, string> = {
|
||||
pending: 'Processing pending',
|
||||
processing: 'Processing...',
|
||||
completed: 'Ready',
|
||||
failed: 'Processing failed',
|
||||
}
|
||||
|
||||
const status = statusMap[document.processingStatus] || document.processingStatus
|
||||
const chunkText = `${document.chunkCount} chunk${document.chunkCount !== 1 ? 's' : ''}`
|
||||
|
||||
return `${status} • ${chunkText}`
|
||||
}
|
||||
|
||||
const label = subBlock.placeholder || 'Select document'
|
||||
const isLoading = documentsLoading && !error
|
||||
|
||||
// Always use cached display name
|
||||
const displayName = useDisplayNamesStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (!normalizedKnowledgeBaseId || !value || typeof value !== 'string') return null
|
||||
return state.cache.documents[normalizedKnowledgeBaseId]?.[value] || null
|
||||
},
|
||||
[normalizedKnowledgeBaseId, value]
|
||||
)
|
||||
const selectorContext = useMemo<SelectorContext>(
|
||||
() => ({
|
||||
knowledgeBaseId: normalizedKnowledgeBaseId ?? undefined,
|
||||
}),
|
||||
[normalizedKnowledgeBaseId]
|
||||
)
|
||||
|
||||
const handleDocumentChange = useCallback(
|
||||
(documentId: string) => {
|
||||
if (isPreview) return
|
||||
onDocumentSelect?.(documentId)
|
||||
},
|
||||
[isPreview, onDocumentSelect]
|
||||
)
|
||||
|
||||
const missingKnowledgeBase = !normalizedKnowledgeBaseId
|
||||
const isDisabled = finalDisabled || missingKnowledgeBase
|
||||
const placeholder = subBlock.placeholder || 'Select document'
|
||||
|
||||
return (
|
||||
<div className='w-full'>
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
role='combobox'
|
||||
aria-expanded={open}
|
||||
className='relative w-full justify-between'
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div className='w-full'>
|
||||
<SelectorCombobox
|
||||
blockId={blockId}
|
||||
subBlock={subBlock}
|
||||
selectorKey='knowledge.documents'
|
||||
selectorContext={selectorContext}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<div className='flex max-w-[calc(100%-20px)] items-center gap-2 overflow-hidden'>
|
||||
<FileText className='h-4 w-4 text-muted-foreground' />
|
||||
{displayName ? (
|
||||
<span className='truncate font-normal'>{displayName}</span>
|
||||
) : (
|
||||
<span className='truncate text-muted-foreground'>{label}</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown className='absolute right-3 h-4 w-4 shrink-0 opacity-50' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-[300px] p-0' align='start'>
|
||||
<Command>
|
||||
<CommandInput placeholder='Search documents...' />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{isLoading ? (
|
||||
<div className='flex items-center justify-center p-4'>
|
||||
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||
<span className='ml-2'>Loading documents...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='text-destructive text-sm'>{error}</p>
|
||||
</div>
|
||||
) : !normalizedKnowledgeBaseId ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No knowledge base selected</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Please select a knowledge base first.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No documents found</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Upload documents to this knowledge base to get started.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CommandEmpty>
|
||||
|
||||
{documents.length > 0 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
Documents
|
||||
</div>
|
||||
{documents.map((document) => (
|
||||
<CommandItem
|
||||
key={document.id}
|
||||
value={`doc-${document.id}-${document.filename}`}
|
||||
onSelect={() => handleSelectDocument(document)}
|
||||
className='cursor-pointer'
|
||||
>
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
<FileText className='h-4 w-4 text-muted-foreground' />
|
||||
<div className='min-w-0 flex-1 overflow-hidden'>
|
||||
<div className='truncate font-normal'>{formatDocumentName(document)}</div>
|
||||
<div className='truncate text-muted-foreground text-xs'>
|
||||
{getDocumentDescription(document)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{document.id === value && <Check className='ml-auto h-4 w-4' />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
isPreview={isPreview}
|
||||
previewValue={previewValue ?? null}
|
||||
placeholder={placeholder}
|
||||
onOptionChange={handleDocumentChange}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
{missingKnowledgeBase && (
|
||||
<Tooltip.Content side='top'>
|
||||
<p>Select a knowledge base first.</p>
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,630 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Check, ChevronDown, ExternalLink, RefreshCw, X } from 'lucide-react'
|
||||
import { ConfluenceIcon } from '@/components/icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
type Credential,
|
||||
getProviderIdFromServiceId,
|
||||
getServiceIdFromScopes,
|
||||
type OAuthProvider,
|
||||
} from '@/lib/oauth'
|
||||
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
|
||||
import { useDisplayNamesStore } from '@/stores/display-names/store'
|
||||
|
||||
const logger = createLogger('ConfluenceFileSelector')
|
||||
|
||||
export interface ConfluenceFileInfo {
|
||||
id: string
|
||||
name: string
|
||||
mimeType: string
|
||||
webViewLink?: string
|
||||
modifiedTime?: string
|
||||
spaceId?: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
interface ConfluenceFileSelectorProps {
|
||||
value: string
|
||||
onChange: (value: string, fileInfo?: ConfluenceFileInfo) => void
|
||||
provider: OAuthProvider
|
||||
requiredScopes?: string[]
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
serviceId?: string
|
||||
domain: string
|
||||
showPreview?: boolean
|
||||
onFileInfoChange?: (fileInfo: ConfluenceFileInfo | null) => void
|
||||
credentialId?: string
|
||||
workflowId?: string
|
||||
isForeignCredential?: boolean
|
||||
}
|
||||
|
||||
export function ConfluenceFileSelector({
|
||||
value,
|
||||
onChange,
|
||||
provider,
|
||||
requiredScopes = [],
|
||||
label = 'Select Confluence page',
|
||||
disabled = false,
|
||||
serviceId,
|
||||
domain,
|
||||
showPreview = true,
|
||||
onFileInfoChange,
|
||||
credentialId,
|
||||
workflowId,
|
||||
isForeignCredential = false,
|
||||
}: ConfluenceFileSelectorProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [credentials, setCredentials] = useState<Credential[]>([])
|
||||
const [files, setFiles] = useState<ConfluenceFileInfo[]>([])
|
||||
const [selectedCredentialId, setSelectedCredentialId] = useState<string>(credentialId || '')
|
||||
const [selectedFileId, setSelectedFileId] = useState(value)
|
||||
const [selectedFile, setSelectedFile] = useState<ConfluenceFileInfo | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [showOAuthModal, setShowOAuthModal] = useState(false)
|
||||
const initialFetchRef = useRef(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Get cached display name
|
||||
const cachedFileName = useDisplayNamesStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
const effectiveCredentialId = credentialId || selectedCredentialId
|
||||
if (!effectiveCredentialId || !value) return null
|
||||
return state.cache.files[effectiveCredentialId]?.[value] || null
|
||||
},
|
||||
[credentialId, selectedCredentialId, value]
|
||||
)
|
||||
)
|
||||
// Keep internal credential in sync with prop (handles late arrival and BFCache restores)
|
||||
useEffect(() => {
|
||||
if (credentialId && credentialId !== selectedCredentialId) {
|
||||
setSelectedCredentialId(credentialId)
|
||||
}
|
||||
}, [credentialId, selectedCredentialId])
|
||||
|
||||
// Handle search with debounce
|
||||
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
// Clear any existing timeout
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current)
|
||||
}
|
||||
|
||||
// Set a new timeout
|
||||
searchTimeoutRef.current = setTimeout(() => {
|
||||
if (value.length > 2) {
|
||||
fetchFiles(value)
|
||||
} else if (value.length === 0) {
|
||||
fetchFiles()
|
||||
}
|
||||
}, 500) // 500ms debounce
|
||||
}
|
||||
|
||||
// Clean up the timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Determine the appropriate service ID based on provider and scopes
|
||||
const getServiceId = (): string => {
|
||||
if (serviceId) return serviceId
|
||||
return getServiceIdFromScopes(provider, requiredScopes)
|
||||
}
|
||||
|
||||
// Determine the appropriate provider ID based on service and scopes
|
||||
const getProviderId = (): string => {
|
||||
const effectiveServiceId = getServiceId()
|
||||
return getProviderIdFromServiceId(effectiveServiceId)
|
||||
}
|
||||
|
||||
// Fetch available credentials for this provider
|
||||
const fetchCredentials = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const providerId = getProviderId()
|
||||
const response = await fetch(`/api/auth/oauth/credentials?provider=${providerId}`)
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setCredentials(data.credentials)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching credentials:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [provider, getProviderId, selectedCredentialId])
|
||||
|
||||
// Fetch page info when we have a selected file ID
|
||||
const fetchPageInfo = useCallback(
|
||||
async (pageId: string) => {
|
||||
if (!selectedCredentialId || !domain) return
|
||||
|
||||
// Validate domain format
|
||||
const trimmedDomain = domain.trim().toLowerCase()
|
||||
if (!trimmedDomain.includes('.')) {
|
||||
setError(
|
||||
'Invalid domain format. Please provide the full domain (e.g., your-site.atlassian.net)'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// Get the access token from the selected credential
|
||||
const tokenResponse = await fetch('/api/auth/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
credentialId: selectedCredentialId,
|
||||
workflowId,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorData = await tokenResponse.json()
|
||||
throw new Error(errorData.error || 'Failed to get access token')
|
||||
}
|
||||
|
||||
const tokenData = await tokenResponse.json()
|
||||
const accessToken = tokenData.accessToken
|
||||
|
||||
// Use the access token to fetch the page info
|
||||
const response = await fetch('/api/tools/confluence/page', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
domain,
|
||||
accessToken,
|
||||
pageId,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to fetch page info')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const fileInfo: ConfluenceFileInfo = {
|
||||
id: data.id || pageId,
|
||||
name: data.title || `Page ${pageId}`,
|
||||
mimeType: 'confluence/page',
|
||||
webViewLink: `https://${domain}/wiki/pages/${data.id}`,
|
||||
modifiedTime: data.version?.when,
|
||||
spaceId: data.spaceId,
|
||||
url: `https://${domain}/wiki/pages/${data.id}`,
|
||||
}
|
||||
setSelectedFile(fileInfo)
|
||||
onFileInfoChange?.(fileInfo)
|
||||
|
||||
// Cache the page name in display names store
|
||||
if (selectedCredentialId) {
|
||||
useDisplayNamesStore
|
||||
.getState()
|
||||
.setDisplayNames('files', selectedCredentialId, { [fileInfo.id]: fileInfo.name })
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching page info:', error)
|
||||
setError((error as Error).message)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
},
|
||||
[selectedCredentialId, domain, onFileInfoChange, workflowId]
|
||||
)
|
||||
|
||||
// Fetch pages from Confluence
|
||||
const fetchFiles = useCallback(
|
||||
async (searchQuery?: string) => {
|
||||
if (!selectedCredentialId || !domain) return
|
||||
if (isForeignCredential) return
|
||||
|
||||
// Validate domain format
|
||||
const trimmedDomain = domain.trim().toLowerCase()
|
||||
if (!trimmedDomain.includes('.')) {
|
||||
setError(
|
||||
'Invalid domain format. Please provide the full domain (e.g., your-site.atlassian.net)'
|
||||
)
|
||||
setFiles([])
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// Get the access token from the selected credential
|
||||
const tokenResponse = await fetch('/api/auth/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
credentialId: selectedCredentialId,
|
||||
workflowId,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorData = await tokenResponse.json()
|
||||
logger.error('Access token error:', errorData)
|
||||
|
||||
// If there's a token error, we might need to reconnect the account
|
||||
setError('Authentication failed. Please reconnect your Confluence account.')
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const tokenData = await tokenResponse.json()
|
||||
const accessToken = tokenData.accessToken
|
||||
|
||||
if (!accessToken) {
|
||||
logger.error('No access token returned')
|
||||
setError('Authentication failed. Please reconnect your Confluence account.')
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Simply fetch pages directly using the endpoint
|
||||
const response = await fetch('/api/tools/confluence/pages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
domain,
|
||||
accessToken,
|
||||
title: searchQuery || undefined,
|
||||
limit: 50,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
logger.info('Confluence pages fetch unauthorized (expected for collaborator)')
|
||||
setFiles([])
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
logger.error('Confluence API error:', errorData)
|
||||
throw new Error(errorData.error || 'Failed to fetch pages')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
logger.info(`Received ${data.files?.length || 0} files from API`)
|
||||
setFiles(data.files || [])
|
||||
|
||||
// Cache file names in display names store
|
||||
if (selectedCredentialId && data.files) {
|
||||
const fileMap = data.files.reduce(
|
||||
(acc: Record<string, string>, file: ConfluenceFileInfo) => {
|
||||
acc[file.id] = file.name
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
useDisplayNamesStore.getState().setDisplayNames('files', selectedCredentialId, fileMap)
|
||||
}
|
||||
|
||||
// If we have a selected file ID, update state and notify parent
|
||||
if (selectedFileId) {
|
||||
const fileInfo = data.files.find((file: ConfluenceFileInfo) => file.id === selectedFileId)
|
||||
if (fileInfo) {
|
||||
setSelectedFile(fileInfo)
|
||||
onFileInfoChange?.(fileInfo)
|
||||
} else if (!searchQuery && selectedFileId) {
|
||||
// If we can't find the file in the list, try to fetch it directly
|
||||
fetchPageInfo(selectedFileId)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching pages:', error)
|
||||
setError((error as Error).message)
|
||||
setFiles([])
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
},
|
||||
[
|
||||
selectedCredentialId,
|
||||
domain,
|
||||
selectedFileId,
|
||||
onFileInfoChange,
|
||||
fetchPageInfo,
|
||||
workflowId,
|
||||
isForeignCredential,
|
||||
]
|
||||
)
|
||||
|
||||
// Fetch credentials on initial mount
|
||||
useEffect(() => {
|
||||
if (!initialFetchRef.current) {
|
||||
fetchCredentials()
|
||||
initialFetchRef.current = true
|
||||
}
|
||||
}, [fetchCredentials])
|
||||
|
||||
// Only fetch files when the dropdown is opened, not on credential selection
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
setOpen(isOpen)
|
||||
|
||||
// Only fetch files when opening the dropdown and if we have valid credentials and domain
|
||||
if (isOpen && !isForeignCredential && selectedCredentialId && domain && domain.includes('.')) {
|
||||
fetchFiles()
|
||||
}
|
||||
}
|
||||
|
||||
// Keep internal selectedFileId in sync with the value prop
|
||||
useEffect(() => {
|
||||
if (value !== selectedFileId) {
|
||||
setSelectedFileId(value)
|
||||
}
|
||||
}, [value])
|
||||
|
||||
// Clear callback when value is cleared
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
setSelectedFile(null)
|
||||
onFileInfoChange?.(null)
|
||||
}
|
||||
}, [value, onFileInfoChange])
|
||||
|
||||
// Fetch page info on mount if we have a value but no selectedFile state
|
||||
useEffect(() => {
|
||||
if (value && selectedCredentialId && domain && !selectedFile) {
|
||||
fetchPageInfo(value)
|
||||
}
|
||||
}, [value, selectedCredentialId, domain, selectedFile, fetchPageInfo])
|
||||
|
||||
// Handle file selection
|
||||
const handleSelectFile = (file: ConfluenceFileInfo) => {
|
||||
setSelectedFileId(file.id)
|
||||
setSelectedFile(file)
|
||||
onChange(file.id, file)
|
||||
onFileInfoChange?.(file)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
// Handle adding a new credential
|
||||
const handleAddCredential = () => {
|
||||
// Show the OAuth modal
|
||||
setShowOAuthModal(true)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
// Clear selection
|
||||
const handleClearSelection = () => {
|
||||
setSelectedFileId('')
|
||||
onChange('', undefined)
|
||||
onFileInfoChange?.(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-2'>
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
role='combobox'
|
||||
aria-expanded={open}
|
||||
className='h-10 w-full min-w-0 justify-between'
|
||||
disabled={disabled || !domain || isForeignCredential}
|
||||
>
|
||||
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
|
||||
{cachedFileName ? (
|
||||
<>
|
||||
<ConfluenceIcon className='h-4 w-4' />
|
||||
<span className='truncate font-normal'>{cachedFileName}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ConfluenceIcon className='h-4 w-4' />
|
||||
<span className='truncate text-muted-foreground'>{label}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
{!isForeignCredential && (
|
||||
<PopoverContent className='w-[300px] p-0' align='start'>
|
||||
{/* Current account indicator */}
|
||||
{selectedCredentialId && credentials.length > 0 && (
|
||||
<div className='flex items-center justify-between border-b px-3 py-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<ConfluenceIcon className='h-4 w-4' />
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
|
||||
'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
{credentials.length > 1 && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 px-2 text-xs'
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Switch
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Command>
|
||||
<CommandInput placeholder='Search pages...' onValueChange={handleSearch} />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{isLoading ? (
|
||||
<div className='flex items-center justify-center p-4'>
|
||||
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||
<span className='ml-2'>Loading pages...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='text-destructive text-sm'>{error}</p>
|
||||
</div>
|
||||
) : credentials.length === 0 ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No accounts connected.</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Connect a Confluence account to continue.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No pages found.</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Try a different search or account.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CommandEmpty>
|
||||
|
||||
{/* Account selection - only show if we have multiple accounts */}
|
||||
{credentials.length > 1 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
Switch Account
|
||||
</div>
|
||||
{credentials.map((cred) => (
|
||||
<CommandItem
|
||||
key={cred.id}
|
||||
value={`account-${cred.id}`}
|
||||
onSelect={() => setSelectedCredentialId(cred.id)}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<ConfluenceIcon className='h-4 w-4' />
|
||||
<span className='font-normal'>{cred.name}</span>
|
||||
</div>
|
||||
{cred.id === selectedCredentialId && (
|
||||
<Check className='ml-auto h-4 w-4' />
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Files list */}
|
||||
{files.length > 0 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
Pages
|
||||
</div>
|
||||
{files.map((file) => (
|
||||
<CommandItem
|
||||
key={file.id}
|
||||
value={`file-${file.id}-${file.name}`}
|
||||
onSelect={() => handleSelectFile(file)}
|
||||
>
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
<ConfluenceIcon className='h-4 w-4' />
|
||||
<span className='truncate font-normal'>{file.name}</span>
|
||||
</div>
|
||||
{file.id === selectedFileId && <Check className='ml-auto h-4 w-4' />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Connect account option - only show if no credentials */}
|
||||
{credentials.length === 0 && (
|
||||
<CommandGroup>
|
||||
<CommandItem onSelect={handleAddCredential}>
|
||||
<div className='flex items-center gap-2 text-foreground'>
|
||||
<ConfluenceIcon className='h-4 w-4' />
|
||||
<span>Connect Confluence account</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
)}
|
||||
</Popover>
|
||||
|
||||
{showPreview && selectedFile && selectedFileId && selectedFile.id === selectedFileId && (
|
||||
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
|
||||
<div className='absolute top-2 right-2'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-5 w-5 hover:bg-muted'
|
||||
onClick={handleClearSelection}
|
||||
>
|
||||
<X className='h-3 w-3' />
|
||||
</Button>
|
||||
</div>
|
||||
<div className='flex items-center gap-3 pr-4'>
|
||||
<div className='flex h-6 w-6 flex-shrink-0 items-center justify-center rounded bg-muted/20'>
|
||||
<ConfluenceIcon className='h-4 w-4' />
|
||||
</div>
|
||||
<div className='min-w-0 flex-1 overflow-hidden'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<h4 className='truncate font-medium text-xs'>{selectedFile.name}</h4>
|
||||
{selectedFile.modifiedTime && (
|
||||
<span className='whitespace-nowrap text-muted-foreground text-xs'>
|
||||
{new Date(selectedFile.modifiedTime).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{selectedFile.webViewLink && (
|
||||
<a
|
||||
href={selectedFile.webViewLink}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='flex items-center gap-1 text-foreground text-xs hover:underline'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span>Open in Confluence</span>
|
||||
<ExternalLink className='h-3 w-3' />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showOAuthModal && (
|
||||
<OAuthRequiredModal
|
||||
isOpen={showOAuthModal}
|
||||
onClose={() => setShowOAuthModal(false)}
|
||||
provider={provider}
|
||||
toolName='Confluence'
|
||||
requiredScopes={requiredScopes}
|
||||
serviceId={getServiceId()}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,288 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Check, ChevronDown, RefreshCw, X } from 'lucide-react'
|
||||
import { GoogleCalendarIcon } from '@/components/icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { useDisplayNamesStore } from '@/stores/display-names/store'
|
||||
|
||||
const logger = createLogger('GoogleCalendarSelector')
|
||||
|
||||
export interface GoogleCalendarInfo {
|
||||
id: string
|
||||
summary: string
|
||||
description?: string
|
||||
primary?: boolean
|
||||
accessRole: string
|
||||
backgroundColor?: string
|
||||
foregroundColor?: string
|
||||
}
|
||||
|
||||
interface GoogleCalendarSelectorProps {
|
||||
value: string
|
||||
onChange: (value: string, calendarInfo?: GoogleCalendarInfo) => void
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
showPreview?: boolean
|
||||
onCalendarInfoChange?: (info: GoogleCalendarInfo | null) => void
|
||||
credentialId: string
|
||||
workflowId?: string
|
||||
}
|
||||
|
||||
export function GoogleCalendarSelector({
|
||||
value,
|
||||
onChange,
|
||||
label = 'Select Google Calendar',
|
||||
disabled = false,
|
||||
showPreview = true,
|
||||
onCalendarInfoChange,
|
||||
credentialId,
|
||||
workflowId,
|
||||
}: GoogleCalendarSelectorProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [calendars, setCalendars] = useState<GoogleCalendarInfo[]>([])
|
||||
const [selectedCalendarId, setSelectedCalendarId] = useState(value)
|
||||
const [selectedCalendar, setSelectedCalendar] = useState<GoogleCalendarInfo | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [initialFetchDone, setInitialFetchDone] = useState(false)
|
||||
|
||||
// Get cached display name
|
||||
const cachedCalendarName = useDisplayNamesStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (!credentialId || !value) return null
|
||||
return state.cache.files[credentialId]?.[value] || null
|
||||
},
|
||||
[credentialId, value]
|
||||
)
|
||||
)
|
||||
|
||||
const fetchCalendarsFromAPI = useCallback(async (): Promise<GoogleCalendarInfo[]> => {
|
||||
if (!credentialId) {
|
||||
throw new Error('Google Calendar account is required')
|
||||
}
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
credentialId: credentialId,
|
||||
})
|
||||
if (workflowId) {
|
||||
queryParams.set('workflowId', workflowId)
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/tools/google_calendar/calendars?${queryParams.toString()}`)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to fetch Google Calendar calendars')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data.calendars || []
|
||||
}, [credentialId])
|
||||
|
||||
const fetchCalendars = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const calendars = await fetchCalendarsFromAPI()
|
||||
setCalendars(calendars)
|
||||
|
||||
// Cache calendar names
|
||||
if (credentialId && calendars.length > 0) {
|
||||
const calendarMap = calendars.reduce<Record<string, string>>((acc, cal) => {
|
||||
acc[cal.id] = cal.summary
|
||||
return acc
|
||||
}, {})
|
||||
useDisplayNamesStore.getState().setDisplayNames('files', credentialId, calendarMap)
|
||||
}
|
||||
|
||||
// Update selected calendar if we have a value
|
||||
if (selectedCalendarId && calendars.length > 0) {
|
||||
const calendar = calendars.find((c) => c.id === selectedCalendarId)
|
||||
setSelectedCalendar(calendar || null)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching calendars:', error)
|
||||
setError((error as Error).message)
|
||||
setCalendars([])
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
setInitialFetchDone(true)
|
||||
}
|
||||
}, [fetchCalendarsFromAPI, credentialId])
|
||||
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
setOpen(isOpen)
|
||||
|
||||
if (isOpen && credentialId && (!initialFetchDone || calendars.length === 0)) {
|
||||
fetchCalendars()
|
||||
}
|
||||
}
|
||||
|
||||
// Sync selected ID with external value
|
||||
useEffect(() => {
|
||||
if (value !== selectedCalendarId) {
|
||||
setSelectedCalendarId(value)
|
||||
}
|
||||
}, [value, selectedCalendarId])
|
||||
|
||||
// Handle calendar selection
|
||||
const handleSelectCalendar = (calendar: GoogleCalendarInfo) => {
|
||||
setSelectedCalendarId(calendar.id)
|
||||
setSelectedCalendar(calendar)
|
||||
onChange(calendar.id, calendar)
|
||||
onCalendarInfoChange?.(calendar)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
// Clear selection
|
||||
const handleClearSelection = () => {
|
||||
setSelectedCalendarId('')
|
||||
onChange('', undefined)
|
||||
onCalendarInfoChange?.(null)
|
||||
setError(null)
|
||||
}
|
||||
|
||||
// Get calendar display name
|
||||
const getCalendarDisplayName = (calendar: GoogleCalendarInfo) => {
|
||||
if (calendar.primary) {
|
||||
return `${calendar.summary} (Primary)`
|
||||
}
|
||||
return calendar.summary
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-2'>
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
role='combobox'
|
||||
aria-expanded={open}
|
||||
className='h-10 w-full min-w-0 justify-between'
|
||||
disabled={disabled || !credentialId}
|
||||
>
|
||||
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
|
||||
{cachedCalendarName ? (
|
||||
<>
|
||||
<GoogleCalendarIcon className='h-4 w-4' />
|
||||
<span className='truncate font-normal'>{cachedCalendarName}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GoogleCalendarIcon className='h-4 w-4' />
|
||||
<span className='truncate text-muted-foreground'>{label}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-[300px] p-0' align='start'>
|
||||
<Command>
|
||||
<CommandInput placeholder='Search calendars...' />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{isLoading ? (
|
||||
<div className='flex items-center justify-center p-4'>
|
||||
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||
<span className='ml-2'>Loading calendars...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='text-destructive text-sm'>{error}</p>
|
||||
</div>
|
||||
) : calendars.length === 0 ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No calendars found</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Please check your Google Calendar account access
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No matching calendars</p>
|
||||
</div>
|
||||
)}
|
||||
</CommandEmpty>
|
||||
|
||||
{calendars.length > 0 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
Calendars
|
||||
</div>
|
||||
{calendars.map((calendar) => (
|
||||
<CommandItem
|
||||
key={calendar.id}
|
||||
value={`calendar-${calendar.id}-${calendar.summary}`}
|
||||
onSelect={() => handleSelectCalendar(calendar)}
|
||||
className='cursor-pointer'
|
||||
>
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
<div
|
||||
className='h-3 w-3 flex-shrink-0 rounded-full'
|
||||
style={{
|
||||
backgroundColor: calendar.backgroundColor || '#4285f4',
|
||||
}}
|
||||
/>
|
||||
<span className='truncate font-normal'>
|
||||
{getCalendarDisplayName(calendar)}
|
||||
</span>
|
||||
</div>
|
||||
{calendar.id === selectedCalendarId && <Check className='ml-auto h-4 w-4' />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{showPreview && selectedCalendar && (
|
||||
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
|
||||
<div className='absolute top-2 right-2'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-5 w-5 hover:bg-muted'
|
||||
onClick={handleClearSelection}
|
||||
>
|
||||
<X className='h-3 w-3' />
|
||||
</Button>
|
||||
</div>
|
||||
<div className='flex items-center gap-3 pr-4'>
|
||||
<div className='flex h-6 w-6 flex-shrink-0 items-center justify-center rounded bg-muted/20'>
|
||||
<div
|
||||
className='h-3 w-3 rounded-full'
|
||||
style={{
|
||||
backgroundColor: selectedCalendar.backgroundColor || '#4285f4',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className='min-w-0 flex-1 overflow-hidden'>
|
||||
<h4 className='truncate font-medium text-xs'>
|
||||
{getCalendarDisplayName(selectedCalendar)}
|
||||
</h4>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
Access: {selectedCalendar.accessRole}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,572 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { ExternalLink, FileIcon, FolderIcon, RefreshCw, X } from 'lucide-react'
|
||||
import useDrivePicker from 'react-google-drive-picker'
|
||||
import { GoogleDocsIcon, GoogleSheetsIcon } from '@/components/icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { getEnv } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
type Credential,
|
||||
getProviderIdFromServiceId,
|
||||
getServiceByProviderAndId,
|
||||
getServiceIdFromScopes,
|
||||
OAUTH_PROVIDERS,
|
||||
type OAuthProvider,
|
||||
parseProvider,
|
||||
} from '@/lib/oauth'
|
||||
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
|
||||
import { useDisplayNamesStore } from '@/stores/display-names/store'
|
||||
|
||||
const logger = createLogger('GoogleDrivePicker')
|
||||
|
||||
export interface FileInfo {
|
||||
id: string
|
||||
name: string
|
||||
mimeType: string
|
||||
iconLink?: string
|
||||
webViewLink?: string
|
||||
thumbnailLink?: string
|
||||
createdTime?: string
|
||||
modifiedTime?: string
|
||||
size?: string
|
||||
owners?: { displayName: string; emailAddress: string }[]
|
||||
}
|
||||
|
||||
interface GoogleDrivePickerProps {
|
||||
value: string
|
||||
onChange: (value: string, fileInfo?: FileInfo) => void
|
||||
provider: OAuthProvider
|
||||
requiredScopes?: string[]
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
serviceId?: string
|
||||
mimeTypeFilter?: string
|
||||
showPreview?: boolean
|
||||
onFileInfoChange?: (fileInfo: FileInfo | null) => void
|
||||
clientId: string
|
||||
apiKey: string
|
||||
credentialId?: string
|
||||
workflowId?: string
|
||||
}
|
||||
|
||||
export function GoogleDrivePicker({
|
||||
value,
|
||||
onChange,
|
||||
provider,
|
||||
requiredScopes = [],
|
||||
label = 'Select file',
|
||||
disabled = false,
|
||||
serviceId,
|
||||
mimeTypeFilter,
|
||||
showPreview = true,
|
||||
onFileInfoChange,
|
||||
clientId,
|
||||
apiKey,
|
||||
credentialId,
|
||||
workflowId,
|
||||
}: GoogleDrivePickerProps) {
|
||||
const [credentials, setCredentials] = useState<Credential[]>([])
|
||||
const [selectedCredentialId, setSelectedCredentialId] = useState<string>('')
|
||||
const [selectedFileId, setSelectedFileId] = useState(value)
|
||||
const [selectedFile, setSelectedFile] = useState<FileInfo | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isLoadingSelectedFile, setIsLoadingSelectedFile] = useState(false)
|
||||
const [showOAuthModal, setShowOAuthModal] = useState(false)
|
||||
const [credentialsLoaded, setCredentialsLoaded] = useState(false)
|
||||
const initialFetchRef = useRef(false)
|
||||
const [openPicker, _authResponse] = useDrivePicker()
|
||||
|
||||
// Determine the appropriate service ID based on provider and scopes
|
||||
const getServiceId = (): string => {
|
||||
if (serviceId) return serviceId
|
||||
return getServiceIdFromScopes(provider, requiredScopes)
|
||||
}
|
||||
|
||||
// Determine the appropriate provider ID based on service and scopes
|
||||
const getProviderId = (): string => {
|
||||
const effectiveServiceId = getServiceId()
|
||||
return getProviderIdFromServiceId(effectiveServiceId)
|
||||
}
|
||||
|
||||
// Fetch available credentials for this provider
|
||||
const fetchCredentials = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
setCredentialsLoaded(false)
|
||||
try {
|
||||
const providerId = getProviderId()
|
||||
const response = await fetch(`/api/auth/oauth/credentials?provider=${providerId}`)
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setCredentials(data.credentials)
|
||||
|
||||
const credentialMap = (data.credentials || []).reduce(
|
||||
(acc: Record<string, string>, cred: Credential) => {
|
||||
acc[cred.id] = cred.name
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
useDisplayNamesStore.getState().setDisplayNames('credentials', providerId, credentialMap)
|
||||
if (credentialId && !data.credentials.some((c: any) => c.id === credentialId)) {
|
||||
setSelectedCredentialId('')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching credentials:', { error })
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
setCredentialsLoaded(true)
|
||||
}
|
||||
}, [provider, getProviderId, selectedCredentialId])
|
||||
|
||||
// Prefer persisted credentialId if provided
|
||||
useEffect(() => {
|
||||
if (credentialId && credentialId !== selectedCredentialId) {
|
||||
setSelectedCredentialId(credentialId)
|
||||
}
|
||||
}, [credentialId, selectedCredentialId])
|
||||
|
||||
// Fetch a single file by ID when we have a selectedFileId but no metadata
|
||||
const fetchFileById = useCallback(
|
||||
async (fileId: string) => {
|
||||
if (!selectedCredentialId || !fileId) return null
|
||||
|
||||
setIsLoadingSelectedFile(true)
|
||||
try {
|
||||
// Construct query parameters
|
||||
const queryParams = new URLSearchParams({
|
||||
credentialId: selectedCredentialId,
|
||||
fileId: fileId,
|
||||
})
|
||||
if (workflowId) queryParams.set('workflowId', workflowId)
|
||||
|
||||
const response = await fetch(`/api/tools/drive/file?${queryParams.toString()}`)
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
if (data.file) {
|
||||
setSelectedFile(data.file)
|
||||
onFileInfoChange?.(data.file)
|
||||
|
||||
// Cache the file name
|
||||
if (selectedCredentialId && data.file.id && data.file.name) {
|
||||
useDisplayNamesStore.getState().setDisplayNames('files', selectedCredentialId, {
|
||||
[data.file.id]: data.file.name,
|
||||
})
|
||||
}
|
||||
|
||||
return data.file
|
||||
}
|
||||
} else {
|
||||
const errorText = await response.text()
|
||||
logger.error('Error fetching file by ID:', { error: errorText })
|
||||
|
||||
// If file not found or access denied, clear the selection
|
||||
if (response.status === 404 || response.status === 403) {
|
||||
logger.info('File not accessible, clearing selection')
|
||||
setSelectedFileId('')
|
||||
onChange('')
|
||||
onFileInfoChange?.(null)
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
logger.info('Credential unauthorized (401), clearing selection and prompting re-auth')
|
||||
setSelectedFileId('')
|
||||
onChange('')
|
||||
onFileInfoChange?.(null)
|
||||
setShowOAuthModal(true)
|
||||
}
|
||||
}
|
||||
return null
|
||||
} catch (error) {
|
||||
logger.error('Error fetching file by ID:', { error })
|
||||
return null
|
||||
} finally {
|
||||
setIsLoadingSelectedFile(false)
|
||||
}
|
||||
},
|
||||
[selectedCredentialId, onChange, onFileInfoChange]
|
||||
)
|
||||
|
||||
// Fetch credentials on initial mount
|
||||
useEffect(() => {
|
||||
if (!initialFetchRef.current) {
|
||||
fetchCredentials()
|
||||
initialFetchRef.current = true
|
||||
}
|
||||
}, [fetchCredentials])
|
||||
|
||||
// Keep internal selectedFileId in sync with the value prop
|
||||
useEffect(() => {
|
||||
if (value !== selectedFileId) {
|
||||
const previousFileId = selectedFileId
|
||||
setSelectedFileId(value)
|
||||
// Only clear selected file info if we had a different file before (not initial load)
|
||||
if (previousFileId && previousFileId !== value && selectedFile) {
|
||||
setSelectedFile(null)
|
||||
}
|
||||
}
|
||||
}, [value, selectedFileId, selectedFile])
|
||||
|
||||
// Track previous credential ID to detect changes
|
||||
const prevCredentialIdRef = useRef<string>('')
|
||||
|
||||
// Clear selected file when credentials are removed or changed
|
||||
useEffect(() => {
|
||||
const prevCredentialId = prevCredentialIdRef.current
|
||||
prevCredentialIdRef.current = selectedCredentialId
|
||||
|
||||
if (!selectedCredentialId) {
|
||||
// No credentials - clear everything
|
||||
if (selectedFile) {
|
||||
setSelectedFile(null)
|
||||
setSelectedFileId('')
|
||||
onChange('')
|
||||
}
|
||||
} else if (prevCredentialId && prevCredentialId !== selectedCredentialId) {
|
||||
// Credentials changed (not initial load) - clear file info to force refetch
|
||||
if (selectedFile) {
|
||||
setSelectedFile(null)
|
||||
}
|
||||
}
|
||||
}, [selectedCredentialId, selectedFile, onChange])
|
||||
|
||||
// Fetch the selected file metadata once credentials are loaded or changed
|
||||
useEffect(() => {
|
||||
// Only fetch if we have both a file ID and credentials, credentials are loaded, but no file info yet
|
||||
if (
|
||||
value &&
|
||||
selectedCredentialId &&
|
||||
credentialsLoaded &&
|
||||
!selectedFile &&
|
||||
!isLoadingSelectedFile
|
||||
) {
|
||||
fetchFileById(value)
|
||||
}
|
||||
}, [
|
||||
value,
|
||||
selectedCredentialId,
|
||||
credentialsLoaded,
|
||||
selectedFile,
|
||||
isLoadingSelectedFile,
|
||||
fetchFileById,
|
||||
])
|
||||
|
||||
// Fetch the access token for the selected credential
|
||||
const fetchAccessToken = async (credentialOverrideId?: string): Promise<string | null> => {
|
||||
const effectiveCredentialId = credentialOverrideId || selectedCredentialId
|
||||
if (!effectiveCredentialId) {
|
||||
logger.error('No credential ID selected for Google Drive Picker')
|
||||
return null
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const response = await fetch('/api/auth/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ credentialId: effectiveCredentialId, workflowId }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch access token: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data.accessToken || null
|
||||
} catch (error) {
|
||||
logger.error('Error fetching access token:', { error })
|
||||
return null
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle opening the Google Drive Picker
|
||||
const handleOpenPicker = async (credentialOverrideId?: string) => {
|
||||
try {
|
||||
// First, get the access token for the selected credential
|
||||
const accessToken = await fetchAccessToken(credentialOverrideId)
|
||||
|
||||
if (!accessToken) {
|
||||
logger.error('Failed to get access token for Google Drive Picker')
|
||||
return
|
||||
}
|
||||
|
||||
const viewIdForMimeType = () => {
|
||||
// Return appropriate view based on mime type filter
|
||||
if (mimeTypeFilter?.includes('folder')) {
|
||||
return 'FOLDERS'
|
||||
}
|
||||
if (mimeTypeFilter?.includes('spreadsheet')) {
|
||||
return 'SPREADSHEETS'
|
||||
}
|
||||
if (mimeTypeFilter?.includes('document')) {
|
||||
return 'DOCUMENTS'
|
||||
}
|
||||
return 'DOCS' // Default view
|
||||
}
|
||||
|
||||
openPicker({
|
||||
clientId,
|
||||
developerKey: apiKey,
|
||||
viewId: viewIdForMimeType(),
|
||||
token: accessToken, // Use the fetched access token
|
||||
showUploadView: true,
|
||||
showUploadFolders: true,
|
||||
supportDrives: true,
|
||||
multiselect: false,
|
||||
appId: getEnv('NEXT_PUBLIC_GOOGLE_PROJECT_NUMBER'),
|
||||
// Enable folder selection when mimeType is folder
|
||||
setSelectFolderEnabled: !!mimeTypeFilter?.includes('folder'),
|
||||
callbackFunction: (data) => {
|
||||
if (data.action === 'picked') {
|
||||
const file = data.docs[0]
|
||||
if (file) {
|
||||
const fileInfo: FileInfo = {
|
||||
id: file.id,
|
||||
name: file.name,
|
||||
mimeType: file.mimeType,
|
||||
iconLink: file.iconUrl,
|
||||
webViewLink: file.url,
|
||||
// thumbnailLink is not directly available from the picker
|
||||
thumbnailLink: file.iconUrl, // Use iconUrl as fallback
|
||||
modifiedTime: file.lastEditedUtc
|
||||
? new Date(file.lastEditedUtc).toISOString()
|
||||
: undefined,
|
||||
}
|
||||
|
||||
setSelectedFileId(file.id)
|
||||
setSelectedFile(fileInfo)
|
||||
onChange(file.id, fileInfo)
|
||||
onFileInfoChange?.(fileInfo)
|
||||
|
||||
// Cache the selected file name
|
||||
if (selectedCredentialId) {
|
||||
useDisplayNamesStore
|
||||
.getState()
|
||||
.setDisplayNames('files', selectedCredentialId, { [file.id]: file.name })
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error opening Google Drive Picker:', { error })
|
||||
}
|
||||
}
|
||||
|
||||
// Handle adding a new credential
|
||||
const handleAddCredential = () => {
|
||||
// Show the OAuth modal
|
||||
setShowOAuthModal(true)
|
||||
}
|
||||
|
||||
// Clear selection
|
||||
const handleClearSelection = () => {
|
||||
setSelectedFileId('')
|
||||
setSelectedFile(null)
|
||||
onChange('', undefined)
|
||||
onFileInfoChange?.(null)
|
||||
}
|
||||
|
||||
// Get provider icon
|
||||
const getProviderIcon = (providerName: OAuthProvider) => {
|
||||
const { baseProvider } = parseProvider(providerName)
|
||||
const baseProviderConfig = OAUTH_PROVIDERS[baseProvider]
|
||||
|
||||
if (!baseProviderConfig) {
|
||||
return <ExternalLink className='h-4 w-4' />
|
||||
}
|
||||
|
||||
// For compound providers, find the specific service
|
||||
if (providerName.includes('-')) {
|
||||
for (const service of Object.values(baseProviderConfig.services)) {
|
||||
if (service.providerId === providerName) {
|
||||
return service.icon({ className: 'h-4 w-4' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to base provider icon
|
||||
return baseProviderConfig.icon({ className: 'h-4 w-4' })
|
||||
}
|
||||
|
||||
// Get provider name
|
||||
const getProviderName = (providerName: OAuthProvider) => {
|
||||
const effectiveServiceId = getServiceId()
|
||||
try {
|
||||
// First try to get the service by provider and service ID
|
||||
const service = getServiceByProviderAndId(providerName, effectiveServiceId)
|
||||
return service.name
|
||||
} catch (_error) {
|
||||
// If that fails, try to get the service by parsing the provider
|
||||
try {
|
||||
const { baseProvider } = parseProvider(providerName)
|
||||
const baseProviderConfig = OAUTH_PROVIDERS[baseProvider]
|
||||
|
||||
// For compound providers like 'google-drive', try to find the specific service
|
||||
if (providerName.includes('-')) {
|
||||
const serviceKey = providerName.split('-')[1] || ''
|
||||
for (const [key, service] of Object.entries(baseProviderConfig?.services || {})) {
|
||||
if (key === serviceKey || key === providerName || service.providerId === providerName) {
|
||||
return service.name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to provider name if service not found
|
||||
if (baseProviderConfig) {
|
||||
return baseProviderConfig.name
|
||||
}
|
||||
} catch (_parseError) {
|
||||
// Ignore parse error and continue to final fallback
|
||||
}
|
||||
|
||||
// Final fallback: capitalize the provider name
|
||||
return providerName
|
||||
.split('-')
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
}
|
||||
|
||||
// Get file icon based on mime type
|
||||
const getFileIcon = (file: FileInfo, size: 'sm' | 'md' = 'sm') => {
|
||||
const iconSize = size === 'sm' ? 'h-4 w-4' : 'h-5 w-5'
|
||||
|
||||
if (file.mimeType === 'application/vnd.google-apps.folder') {
|
||||
return <FolderIcon className={`${iconSize} text-muted-foreground`} />
|
||||
}
|
||||
if (file.mimeType === 'application/vnd.google-apps.spreadsheet') {
|
||||
return <GoogleSheetsIcon className={iconSize} />
|
||||
}
|
||||
if (file.mimeType === 'application/vnd.google-apps.document') {
|
||||
return <GoogleDocsIcon className={iconSize} />
|
||||
}
|
||||
return <FileIcon className={`${iconSize} text-muted-foreground`} />
|
||||
}
|
||||
|
||||
const canShowPreview = !!(
|
||||
showPreview &&
|
||||
selectedFile &&
|
||||
selectedFileId &&
|
||||
selectedFile.id === selectedFileId
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
role='combobox'
|
||||
className='h-10 w-full min-w-0 justify-between'
|
||||
disabled={disabled || isLoading}
|
||||
onClick={async () => {
|
||||
// Decide which credential to use
|
||||
let idToUse = selectedCredentialId
|
||||
if (!idToUse && credentials.length === 1) {
|
||||
idToUse = credentials[0].id
|
||||
setSelectedCredentialId(idToUse)
|
||||
}
|
||||
|
||||
if (!idToUse) {
|
||||
// No credentials — prompt OAuth
|
||||
handleAddCredential()
|
||||
return
|
||||
}
|
||||
|
||||
await handleOpenPicker(idToUse)
|
||||
}}
|
||||
>
|
||||
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
|
||||
{canShowPreview ? (
|
||||
<>
|
||||
{getFileIcon(selectedFile, 'sm')}
|
||||
<span className='truncate font-normal'>{selectedFile.name}</span>
|
||||
</>
|
||||
) : selectedFileId && isLoadingSelectedFile && selectedCredentialId ? (
|
||||
<>
|
||||
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||
<span className='truncate text-muted-foreground'>Loading document...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{getProviderIcon(provider)}
|
||||
<span className='truncate text-muted-foreground'>{label}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{/* File preview */}
|
||||
{canShowPreview && (
|
||||
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
|
||||
<div className='absolute top-2 right-2'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-5 w-5 hover:bg-muted'
|
||||
onClick={handleClearSelection}
|
||||
>
|
||||
<X className='h-3 w-3' />
|
||||
</Button>
|
||||
</div>
|
||||
<div className='flex items-center gap-3 pr-4'>
|
||||
<div className='flex h-6 w-6 flex-shrink-0 items-center justify-center rounded bg-muted/20'>
|
||||
{getFileIcon(selectedFile, 'sm')}
|
||||
</div>
|
||||
<div className='min-w-0 flex-1 overflow-hidden'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<h4 className='truncate font-medium text-xs'>{selectedFile.name}</h4>
|
||||
{selectedFile.modifiedTime && (
|
||||
<span className='whitespace-nowrap text-muted-foreground text-xs'>
|
||||
{new Date(selectedFile.modifiedTime).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{selectedFile.webViewLink ? (
|
||||
<a
|
||||
href={selectedFile.webViewLink}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='flex items-center gap-1 text-muted-foreground text-xs hover:underline'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span>Open in Drive</span>
|
||||
<ExternalLink className='h-3 w-3' />
|
||||
</a>
|
||||
) : (
|
||||
<a
|
||||
href={`https://drive.google.com/file/d/${selectedFile.id}/view`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='flex items-center gap-1 text-muted-foreground text-xs hover:underline'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span>Open in Drive</span>
|
||||
<ExternalLink className='h-3 w-3' />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showOAuthModal && (
|
||||
<OAuthRequiredModal
|
||||
isOpen={showOAuthModal}
|
||||
onClose={() => setShowOAuthModal(false)}
|
||||
provider={provider}
|
||||
toolName={getProviderName(provider)}
|
||||
requiredScopes={requiredScopes}
|
||||
serviceId={getServiceId()}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
export type { ConfluenceFileInfo } from './confluence-file-selector'
|
||||
export { ConfluenceFileSelector } from './confluence-file-selector'
|
||||
export type { GoogleCalendarInfo } from './google-calendar-selector'
|
||||
export { GoogleCalendarSelector } from './google-calendar-selector'
|
||||
export type { FileInfo } from './google-drive-picker'
|
||||
export { GoogleDrivePicker } from './google-drive-picker'
|
||||
export type { JiraIssueInfo } from './jira-issue-selector'
|
||||
export { JiraIssueSelector } from './jira-issue-selector'
|
||||
export type { MicrosoftFileInfo } from './microsoft-file-selector'
|
||||
export { MicrosoftFileSelector } from './microsoft-file-selector'
|
||||
export type { TeamsMessageInfo } from './teams-message-selector'
|
||||
export { TeamsMessageSelector } from './teams-message-selector'
|
||||
export type { WealthboxItemInfo } from './wealthbox-file-selector'
|
||||
export { WealthboxFileSelector } from './wealthbox-file-selector'
|
||||
@@ -1,670 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Check, ChevronDown, ExternalLink, RefreshCw, X } from 'lucide-react'
|
||||
import { JiraIcon } from '@/components/icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
type Credential,
|
||||
getProviderIdFromServiceId,
|
||||
getServiceIdFromScopes,
|
||||
type OAuthProvider,
|
||||
} from '@/lib/oauth'
|
||||
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
|
||||
import { useDisplayNamesStore } from '@/stores/display-names/store'
|
||||
|
||||
const logger = createLogger('JiraIssueSelector')
|
||||
|
||||
export interface JiraIssueInfo {
|
||||
id: string
|
||||
name: string
|
||||
mimeType: string
|
||||
webViewLink?: string
|
||||
modifiedTime?: string
|
||||
spaceId?: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
interface JiraIssueSelectorProps {
|
||||
value: string
|
||||
onChange: (value: string, issueInfo?: JiraIssueInfo) => void
|
||||
provider: OAuthProvider
|
||||
requiredScopes?: string[]
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
serviceId?: string
|
||||
domain: string
|
||||
showPreview?: boolean
|
||||
onIssueInfoChange?: (issueInfo: JiraIssueInfo | null) => void
|
||||
projectId?: string
|
||||
credentialId?: string
|
||||
isForeignCredential?: boolean
|
||||
workflowId?: string
|
||||
}
|
||||
|
||||
export function JiraIssueSelector({
|
||||
value,
|
||||
onChange,
|
||||
provider,
|
||||
requiredScopes = [],
|
||||
label = 'Select Jira issue',
|
||||
disabled = false,
|
||||
serviceId,
|
||||
domain,
|
||||
showPreview = true,
|
||||
onIssueInfoChange,
|
||||
projectId,
|
||||
credentialId,
|
||||
isForeignCredential = false,
|
||||
workflowId,
|
||||
}: JiraIssueSelectorProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [credentials, setCredentials] = useState<Credential[]>([])
|
||||
const [issues, setIssues] = useState<JiraIssueInfo[]>([])
|
||||
const [selectedCredentialId, setSelectedCredentialId] = useState<string>(credentialId || '')
|
||||
const [selectedIssueId, setSelectedIssueId] = useState(value)
|
||||
const [selectedIssue, setSelectedIssue] = useState<JiraIssueInfo | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [showOAuthModal, setShowOAuthModal] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [cloudId, setCloudId] = useState<string | null>(null)
|
||||
|
||||
// Get cached display name
|
||||
const cachedIssueName = useDisplayNamesStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
const effectiveCredentialId = credentialId || selectedCredentialId
|
||||
if (!effectiveCredentialId || !value) return null
|
||||
return state.cache.files[effectiveCredentialId]?.[value] || null
|
||||
},
|
||||
[credentialId, selectedCredentialId, value]
|
||||
)
|
||||
)
|
||||
|
||||
// Keep local credential state in sync with persisted credentialId prop
|
||||
useEffect(() => {
|
||||
if (credentialId && credentialId !== selectedCredentialId) {
|
||||
setSelectedCredentialId(credentialId)
|
||||
} else if (!credentialId && selectedCredentialId) {
|
||||
setSelectedCredentialId('')
|
||||
}
|
||||
}, [credentialId, selectedCredentialId])
|
||||
|
||||
// Handle search with debounce
|
||||
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
// Clear any existing timeout
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current)
|
||||
}
|
||||
|
||||
// Set a new timeout
|
||||
searchTimeoutRef.current = setTimeout(() => {
|
||||
if (value.length >= 1) {
|
||||
// Changed from > 2 to >= 1 to be more responsive
|
||||
fetchIssues(value)
|
||||
} else {
|
||||
setIssues([]) // Clear issues if search is empty
|
||||
}
|
||||
}, 500) // 500ms debounce
|
||||
}
|
||||
|
||||
// Clean up the timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Determine the appropriate service ID based on provider and scopes
|
||||
const getServiceId = (): string => {
|
||||
if (serviceId) return serviceId
|
||||
return getServiceIdFromScopes(provider, requiredScopes)
|
||||
}
|
||||
|
||||
// Determine the appropriate provider ID based on service and scopes (stabilized)
|
||||
const providerId = useMemo(() => {
|
||||
const effectiveServiceId = getServiceId()
|
||||
return getProviderIdFromServiceId(effectiveServiceId)
|
||||
}, [serviceId, provider, requiredScopes])
|
||||
|
||||
// Fetch available credentials for this provider
|
||||
const fetchCredentials = useCallback(async () => {
|
||||
if (!providerId) return
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const response = await fetch(`/api/auth/oauth/credentials?provider=${providerId}`)
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setCredentials(data.credentials)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching credentials:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [providerId])
|
||||
|
||||
// Fetch issue info when we have a selected issue ID
|
||||
const fetchIssueInfo = useCallback(
|
||||
async (issueId: string) => {
|
||||
// Validate domain format
|
||||
const trimmedDomain = domain.trim().toLowerCase()
|
||||
if (!trimmedDomain.includes('.')) {
|
||||
setError(
|
||||
'Invalid domain format. Please provide the full domain (e.g., your-site.atlassian.net)'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// Get the access token from the selected credential
|
||||
const tokenResponse = await fetch('/api/auth/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
credentialId: selectedCredentialId,
|
||||
workflowId,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorData = await tokenResponse.json()
|
||||
throw new Error(errorData.error || 'Failed to get access token')
|
||||
}
|
||||
|
||||
const tokenData = await tokenResponse.json()
|
||||
const accessToken = tokenData.accessToken
|
||||
|
||||
if (!accessToken) {
|
||||
throw new Error('No access token received')
|
||||
}
|
||||
|
||||
// Use the access token to fetch the issue info
|
||||
const response = await fetch('/api/tools/jira/issue', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
domain,
|
||||
accessToken,
|
||||
issueId,
|
||||
cloudId,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
logger.error('Failed to fetch issue info:', errorData)
|
||||
throw new Error(errorData.error || 'Failed to fetch issue info')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
if (data.cloudId) {
|
||||
logger.info('Using cloud ID:', data.cloudId)
|
||||
setCloudId(data.cloudId)
|
||||
}
|
||||
|
||||
if (data.issue) {
|
||||
logger.info('Successfully fetched issue:', data.issue.name)
|
||||
setSelectedIssue(data.issue)
|
||||
onIssueInfoChange?.(data.issue)
|
||||
} else {
|
||||
logger.warn('No issue data received in response')
|
||||
setSelectedIssue(null)
|
||||
onIssueInfoChange?.(null)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching issue info:', error)
|
||||
setError((error as Error).message)
|
||||
onIssueInfoChange?.(null)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
},
|
||||
[selectedCredentialId, domain, onIssueInfoChange, cloudId]
|
||||
)
|
||||
|
||||
// Fetch issues from Jira
|
||||
const fetchIssues = useCallback(
|
||||
async (searchQuery?: string) => {
|
||||
if (!selectedCredentialId || !domain) return
|
||||
// If no search query is provided, require a projectId before fetching
|
||||
if (!searchQuery && !projectId) {
|
||||
setIssues([])
|
||||
return
|
||||
}
|
||||
|
||||
// Validate domain format
|
||||
const trimmedDomain = domain.trim().toLowerCase()
|
||||
if (!trimmedDomain.includes('.')) {
|
||||
setError(
|
||||
'Invalid domain format. Please provide the full domain (e.g., your-site.atlassian.net)'
|
||||
)
|
||||
setIssues([])
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// Get the access token from the selected credential
|
||||
const tokenResponse = await fetch('/api/auth/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
credentialId: selectedCredentialId,
|
||||
workflowId,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorData = await tokenResponse.json()
|
||||
logger.error('Access token error:', errorData)
|
||||
|
||||
// If there's a token error, we might need to reconnect the account
|
||||
setError('Authentication failed. Please reconnect your Jira account.')
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const tokenData = await tokenResponse.json()
|
||||
const accessToken = tokenData.accessToken
|
||||
|
||||
if (!accessToken) {
|
||||
logger.error('No access token returned')
|
||||
setError('Authentication failed. Please reconnect your Jira account.')
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Build query parameters for the issues endpoint
|
||||
const queryParams = new URLSearchParams({
|
||||
domain,
|
||||
accessToken,
|
||||
...(projectId && { projectId }),
|
||||
...(searchQuery && { query: searchQuery }),
|
||||
...(cloudId && { cloudId }),
|
||||
})
|
||||
|
||||
const response = await fetch(`/api/tools/jira/issues?${queryParams.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
logger.error('Jira API error:', errorData)
|
||||
throw new Error(errorData.error || 'Failed to fetch issues')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.cloudId) {
|
||||
setCloudId(data.cloudId)
|
||||
}
|
||||
|
||||
// Process the issue picker results
|
||||
let foundIssues: JiraIssueInfo[] = []
|
||||
|
||||
// Handle the sections returned by the issue picker API
|
||||
if (data.sections) {
|
||||
// Combine issues from all sections
|
||||
data.sections.forEach((section: any) => {
|
||||
if (section.issues && section.issues.length > 0) {
|
||||
const sectionIssues = section.issues.map((issue: any) => ({
|
||||
id: issue.key,
|
||||
name: issue.summary || issue.summaryText || issue.key,
|
||||
mimeType: 'jira/issue',
|
||||
url: `https://${domain}/browse/${issue.key}`,
|
||||
webViewLink: `https://${domain}/browse/${issue.key}`,
|
||||
}))
|
||||
foundIssues = [...foundIssues, ...sectionIssues]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
logger.info(`Received ${foundIssues.length} issues from API`)
|
||||
setIssues(foundIssues)
|
||||
|
||||
// Cache issue names in display names store
|
||||
if (selectedCredentialId && foundIssues.length > 0) {
|
||||
const issueMap = foundIssues.reduce(
|
||||
(acc: Record<string, string>, issue: JiraIssueInfo) => {
|
||||
acc[issue.id] = issue.name
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
useDisplayNamesStore.getState().setDisplayNames('files', selectedCredentialId, issueMap)
|
||||
}
|
||||
|
||||
// If we have a selected issue ID, update state and notify parent
|
||||
if (selectedIssueId) {
|
||||
const issueInfo = foundIssues.find((issue: JiraIssueInfo) => issue.id === selectedIssueId)
|
||||
if (issueInfo) {
|
||||
setSelectedIssue(issueInfo)
|
||||
onIssueInfoChange?.(issueInfo)
|
||||
} else if (!searchQuery && selectedIssueId) {
|
||||
// If we can't find the issue in the list, try to fetch it directly
|
||||
fetchIssueInfo(selectedIssueId)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching issues:', error)
|
||||
setError((error as Error).message)
|
||||
setIssues([])
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
},
|
||||
[
|
||||
selectedCredentialId,
|
||||
domain,
|
||||
selectedIssueId,
|
||||
onIssueInfoChange,
|
||||
fetchIssueInfo,
|
||||
cloudId,
|
||||
projectId,
|
||||
]
|
||||
)
|
||||
|
||||
// Fetch credentials when the dropdown opens (avoid fetching on mount with no credential)
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchCredentials()
|
||||
}
|
||||
}, [open, fetchCredentials])
|
||||
|
||||
// Handle open change
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
if (disabled || isForeignCredential) {
|
||||
setOpen(false)
|
||||
return
|
||||
}
|
||||
setOpen(isOpen)
|
||||
|
||||
// Only fetch recent/default issues when opening the dropdown
|
||||
if (isOpen && selectedCredentialId && domain && domain.includes('.')) {
|
||||
// Only fetch on open when a project is selected; otherwise wait for user search
|
||||
if (projectId) {
|
||||
fetchIssues('')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch selected issue metadata once credentials are ready or changed
|
||||
// Keep internal selectedIssueId in sync with the value prop
|
||||
useEffect(() => {
|
||||
if (value !== selectedIssueId) {
|
||||
setSelectedIssueId(value)
|
||||
}
|
||||
// When the upstream value is cleared (e.g., project changed or remote user cleared),
|
||||
// clear local selection and preview immediately
|
||||
if (!value) {
|
||||
setSelectedIssue(null)
|
||||
setIssues([])
|
||||
setError(null)
|
||||
onIssueInfoChange?.(null)
|
||||
}
|
||||
}, [value, onIssueInfoChange])
|
||||
|
||||
// Fetch issue info on mount if we have a value but no selectedIssue state
|
||||
useEffect(() => {
|
||||
if (value && selectedCredentialId && domain && projectId && !selectedIssue) {
|
||||
fetchIssueInfo(value)
|
||||
}
|
||||
}, [value, selectedCredentialId, domain, projectId, selectedIssue, fetchIssueInfo])
|
||||
|
||||
// Handle issue selection
|
||||
const handleSelectIssue = (issue: JiraIssueInfo) => {
|
||||
setSelectedIssueId(issue.id)
|
||||
setSelectedIssue(issue)
|
||||
onChange(issue.id, issue)
|
||||
onIssueInfoChange?.(issue)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
// Handle adding a new credential
|
||||
const handleAddCredential = () => {
|
||||
// Show the OAuth modal
|
||||
setShowOAuthModal(true)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
// Clear selection
|
||||
const handleClearSelection = () => {
|
||||
setSelectedIssueId('')
|
||||
setError(null)
|
||||
onChange('', undefined)
|
||||
onIssueInfoChange?.(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-2'>
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
role='combobox'
|
||||
aria-expanded={open}
|
||||
className='h-10 w-full min-w-0 justify-between'
|
||||
disabled={disabled || !domain || !selectedCredentialId || isForeignCredential}
|
||||
>
|
||||
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
|
||||
{cachedIssueName ? (
|
||||
<>
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
<span className='truncate font-normal'>{cachedIssueName}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
<span className='truncate text-muted-foreground'>{label}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
{!isForeignCredential && (
|
||||
<PopoverContent className='w-[300px] p-0' align='start'>
|
||||
{/* Current account indicator */}
|
||||
{selectedCredentialId && credentials.length > 0 && (
|
||||
<div className='flex items-center justify-between border-b px-3 py-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
|
||||
'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
{credentials.length > 1 && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 px-2 text-xs'
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Switch
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Command>
|
||||
<CommandInput placeholder='Search issues...' onValueChange={handleSearch} />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{isLoading ? (
|
||||
<div className='flex items-center justify-center p-4'>
|
||||
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||
<span className='ml-2'>Loading issues...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='text-destructive text-sm'>{error}</p>
|
||||
</div>
|
||||
) : credentials.length === 0 ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No accounts connected.</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Connect a Jira account to continue.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No issues found.</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Try a different search or account.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CommandEmpty>
|
||||
|
||||
{/* Account selection - only show if we have multiple accounts */}
|
||||
{credentials.length > 1 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
Switch Account
|
||||
</div>
|
||||
{credentials.map((cred) => (
|
||||
<CommandItem
|
||||
key={cred.id}
|
||||
value={`account-${cred.id}`}
|
||||
onSelect={() => setSelectedCredentialId(cred.id)}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
<span className='font-normal'>{cred.name}</span>
|
||||
</div>
|
||||
{cred.id === selectedCredentialId && (
|
||||
<Check className='ml-auto h-4 w-4' />
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Issues list */}
|
||||
{issues.length > 0 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
Issues
|
||||
</div>
|
||||
{issues.map((issue) => (
|
||||
<CommandItem
|
||||
key={issue.id}
|
||||
value={`issue-${issue.id}-${issue.name}`}
|
||||
onSelect={() => handleSelectIssue(issue)}
|
||||
>
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
<span className='truncate font-normal'>{issue.name}</span>
|
||||
</div>
|
||||
{issue.id === selectedIssueId && <Check className='ml-auto h-4 w-4' />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Connect account option - only show if no credentials */}
|
||||
{credentials.length === 0 && (
|
||||
<CommandGroup>
|
||||
<CommandItem onSelect={handleAddCredential}>
|
||||
<div className='flex items-center gap-2 text-foreground'>
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
<span>Connect Jira account</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
)}
|
||||
</Popover>
|
||||
|
||||
{showPreview && selectedIssue && (
|
||||
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
|
||||
<div className='absolute top-2 right-2'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-5 w-5 hover:bg-muted'
|
||||
onClick={handleClearSelection}
|
||||
>
|
||||
<X className='h-3 w-3' />
|
||||
</Button>
|
||||
</div>
|
||||
<div className='flex items-center gap-3 pr-4'>
|
||||
<div className='flex h-6 w-6 flex-shrink-0 items-center justify-center rounded bg-muted/20'>
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
</div>
|
||||
<div className='min-w-0 flex-1 overflow-hidden'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<h4 className='truncate font-medium text-xs'>{selectedIssue.name}</h4>
|
||||
{selectedIssue.modifiedTime && (
|
||||
<span className='whitespace-nowrap text-muted-foreground text-xs'>
|
||||
{new Date(selectedIssue.modifiedTime).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{selectedIssue.webViewLink && (
|
||||
<a
|
||||
href={selectedIssue.webViewLink}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='flex items-center gap-1 text-foreground text-xs hover:underline'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span>Open in Jira</span>
|
||||
<ExternalLink className='h-3 w-3' />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showOAuthModal && (
|
||||
<OAuthRequiredModal
|
||||
isOpen={showOAuthModal}
|
||||
onClose={() => setShowOAuthModal(false)}
|
||||
provider={provider}
|
||||
toolName='Jira'
|
||||
requiredScopes={requiredScopes}
|
||||
serviceId={getServiceId()}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,961 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Check, ChevronDown, ExternalLink, RefreshCw, X } from 'lucide-react'
|
||||
import { MicrosoftTeamsIcon } from '@/components/icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
type Credential,
|
||||
getProviderIdFromServiceId,
|
||||
getServiceIdFromScopes,
|
||||
type OAuthProvider,
|
||||
} from '@/lib/oauth'
|
||||
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
|
||||
import { useDisplayNamesStore } from '@/stores/display-names/store'
|
||||
|
||||
const logger = createLogger('TeamsMessageSelector')
|
||||
|
||||
export interface TeamsMessageInfo {
|
||||
id: string
|
||||
displayName: string
|
||||
type: 'team' | 'channel' | 'chat'
|
||||
teamId?: string
|
||||
channelId?: string
|
||||
chatId?: string
|
||||
webViewLink?: string
|
||||
}
|
||||
|
||||
interface TeamsMessageSelectorProps {
|
||||
value: string
|
||||
onChange: (value: string, messageInfo?: TeamsMessageInfo) => void
|
||||
provider: OAuthProvider
|
||||
requiredScopes?: string[]
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
serviceId?: string
|
||||
showPreview?: boolean
|
||||
onMessageInfoChange?: (messageInfo: TeamsMessageInfo | null) => void
|
||||
credential: string
|
||||
selectionType?: 'team' | 'channel' | 'chat'
|
||||
initialTeamId?: string
|
||||
workflowId: string
|
||||
isForeignCredential?: boolean
|
||||
}
|
||||
|
||||
export function TeamsMessageSelector({
|
||||
value,
|
||||
onChange,
|
||||
provider,
|
||||
requiredScopes = [],
|
||||
label = 'Select Teams message location',
|
||||
disabled = false,
|
||||
serviceId,
|
||||
showPreview = true,
|
||||
onMessageInfoChange,
|
||||
credential,
|
||||
selectionType = 'team',
|
||||
initialTeamId,
|
||||
workflowId,
|
||||
isForeignCredential = false,
|
||||
}: TeamsMessageSelectorProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [credentials, setCredentials] = useState<Credential[]>([])
|
||||
const [teams, setTeams] = useState<TeamsMessageInfo[]>([])
|
||||
const [channels, setChannels] = useState<TeamsMessageInfo[]>([])
|
||||
const [chats, setChats] = useState<TeamsMessageInfo[]>([])
|
||||
const [selectedCredentialId, setSelectedCredentialId] = useState<string>(credential || '')
|
||||
const [selectedTeamId, setSelectedTeamId] = useState<string>('')
|
||||
const [selectedChannelId, setSelectedChannelId] = useState<string>('')
|
||||
const [selectedChatId, setSelectedChatId] = useState<string>('')
|
||||
const [selectedMessageId, setSelectedMessageId] = useState(value)
|
||||
const [selectedMessage, setSelectedMessage] = useState<TeamsMessageInfo | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [showOAuthModal, setShowOAuthModal] = useState(false)
|
||||
const initialFetchRef = useRef(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [selectionStage, setSelectionStage] = useState<'team' | 'channel' | 'chat'>(selectionType)
|
||||
const lastRestoredValueRef = useRef<string | null>(null)
|
||||
|
||||
// Get cached display name
|
||||
const cachedMessageName = useDisplayNamesStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (!credential || !value) return null
|
||||
return state.cache.files[credential]?.[value] || null
|
||||
},
|
||||
[credential, value]
|
||||
)
|
||||
)
|
||||
|
||||
// Determine the appropriate service ID based on provider and scopes
|
||||
const getServiceId = (): string => {
|
||||
if (serviceId) return serviceId
|
||||
return getServiceIdFromScopes(provider, requiredScopes)
|
||||
}
|
||||
|
||||
// Determine the appropriate provider ID based on service and scopes
|
||||
const getProviderId = (): string => {
|
||||
const effectiveServiceId = getServiceId()
|
||||
return getProviderIdFromServiceId(effectiveServiceId)
|
||||
}
|
||||
|
||||
const fetchCredentials = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const providerId = getProviderId()
|
||||
const response = await fetch(`/api/auth/oauth/credentials?provider=${providerId}`)
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setCredentials(data.credentials)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching credentials:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [provider, getProviderId, selectedCredentialId])
|
||||
|
||||
// Fetch teams
|
||||
const fetchTeams = useCallback(async () => {
|
||||
if (!selectedCredentialId) return
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/tools/microsoft-teams/teams', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
credential: selectedCredentialId,
|
||||
workflowId,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
|
||||
// If server indicates auth is required, show the auth modal
|
||||
if (response.status === 401 && errorData.authRequired) {
|
||||
logger.warn('Authentication required for Microsoft Teams')
|
||||
setShowOAuthModal(true)
|
||||
throw new Error('Microsoft Teams authentication required')
|
||||
}
|
||||
|
||||
throw new Error(errorData.error || 'Failed to fetch teams')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const teamsData = data.teams.map((team: { id: string; displayName: string }) => ({
|
||||
id: team.id,
|
||||
displayName: team.displayName,
|
||||
type: 'team' as const,
|
||||
teamId: team.id,
|
||||
webViewLink: `https://teams.microsoft.com/l/team/${team.id}`,
|
||||
}))
|
||||
|
||||
setTeams(teamsData)
|
||||
|
||||
// Cache team names in display names store
|
||||
if (selectedCredentialId && teamsData.length > 0) {
|
||||
const teamMap = teamsData.reduce((acc: Record<string, string>, team: TeamsMessageInfo) => {
|
||||
acc[team.id] = team.displayName
|
||||
return acc
|
||||
}, {})
|
||||
useDisplayNamesStore.getState().setDisplayNames('files', selectedCredentialId, teamMap)
|
||||
}
|
||||
|
||||
// If we have a selected team ID, find it in the list
|
||||
if (selectedTeamId) {
|
||||
const team = teamsData.find((t: TeamsMessageInfo) => t.teamId === selectedTeamId)
|
||||
if (team) {
|
||||
setSelectedMessage(team)
|
||||
onMessageInfoChange?.(team)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching teams:', error)
|
||||
setError((error as Error).message)
|
||||
setTeams([])
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [selectedCredentialId, selectedTeamId, onMessageInfoChange, workflowId])
|
||||
|
||||
// Fetch channels for a selected team
|
||||
const fetchChannels = useCallback(
|
||||
async (teamId: string) => {
|
||||
if (!selectedCredentialId || !teamId) return
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/tools/microsoft-teams/channels', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
credential: selectedCredentialId,
|
||||
teamId,
|
||||
workflowId,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
|
||||
// If server indicates auth is required, show the auth modal
|
||||
if (response.status === 401 && errorData.authRequired) {
|
||||
logger.warn('Authentication required for Microsoft Teams')
|
||||
setShowOAuthModal(true)
|
||||
throw new Error('Microsoft Teams authentication required')
|
||||
}
|
||||
|
||||
throw new Error(errorData.error || 'Failed to fetch channels')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const channelsData = data.channels.map((channel: { id: string; displayName: string }) => ({
|
||||
id: `${teamId}-${channel.id}`,
|
||||
displayName: channel.displayName,
|
||||
type: 'channel' as const,
|
||||
teamId,
|
||||
channelId: channel.id,
|
||||
webViewLink: `https://teams.microsoft.com/l/channel/${teamId}/${encodeURIComponent(channel.displayName)}/${channel.id}`,
|
||||
}))
|
||||
|
||||
setChannels(channelsData)
|
||||
|
||||
// Cache channel names in display names store
|
||||
if (selectedCredentialId && channelsData.length > 0) {
|
||||
const channelMap = channelsData.reduce(
|
||||
(acc: Record<string, string>, channel: TeamsMessageInfo) => {
|
||||
acc[channel.channelId!] = channel.displayName
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
useDisplayNamesStore.getState().setDisplayNames('files', selectedCredentialId, channelMap)
|
||||
}
|
||||
|
||||
// If we have a selected channel ID, find it in the list
|
||||
if (selectedChannelId) {
|
||||
const channel = channelsData.find(
|
||||
(c: TeamsMessageInfo) => c.channelId === selectedChannelId
|
||||
)
|
||||
if (channel) {
|
||||
setSelectedMessage(channel)
|
||||
onMessageInfoChange?.(channel)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching channels:', error)
|
||||
setError((error as Error).message)
|
||||
setChannels([])
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
},
|
||||
[selectedCredentialId, selectedChannelId, onMessageInfoChange, workflowId]
|
||||
)
|
||||
|
||||
// Fetch chats
|
||||
const fetchChats = useCallback(async () => {
|
||||
if (!selectedCredentialId) return
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/tools/microsoft-teams/chats', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
credential: selectedCredentialId,
|
||||
workflowId: workflowId, // Pass the workflowId for server-side authentication
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
|
||||
// If server indicates auth is required, show the auth modal
|
||||
if (response.status === 401 && errorData.authRequired) {
|
||||
logger.warn('Authentication required for Microsoft Teams')
|
||||
setShowOAuthModal(true)
|
||||
throw new Error('Microsoft Teams authentication required')
|
||||
}
|
||||
|
||||
throw new Error(errorData.error || 'Failed to fetch chats')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const chatsData = data.chats.map((chat: { id: string; displayName: string }) => ({
|
||||
id: chat.id,
|
||||
displayName: chat.displayName,
|
||||
type: 'chat' as const,
|
||||
chatId: chat.id,
|
||||
webViewLink: `https://teams.microsoft.com/l/chat/${chat.id}`,
|
||||
}))
|
||||
|
||||
setChats(chatsData)
|
||||
|
||||
if (selectedCredentialId && chatsData.length > 0) {
|
||||
const chatMap = chatsData.reduce((acc: Record<string, string>, chat: TeamsMessageInfo) => {
|
||||
acc[chat.id] = chat.displayName
|
||||
return acc
|
||||
}, {})
|
||||
useDisplayNamesStore.getState().setDisplayNames('files', selectedCredentialId, chatMap)
|
||||
}
|
||||
|
||||
// If we have a selected chat ID, find it in the list
|
||||
if (selectedChatId) {
|
||||
const chat = chatsData.find((c: TeamsMessageInfo) => c.chatId === selectedChatId)
|
||||
if (chat) {
|
||||
setSelectedMessage(chat)
|
||||
onMessageInfoChange?.(chat)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching chats:', error)
|
||||
setError((error as Error).message)
|
||||
setChats([])
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [selectedCredentialId, selectedChatId, onMessageInfoChange, workflowId])
|
||||
|
||||
// Update selection stage based on selected values and selectionType
|
||||
useEffect(() => {
|
||||
// If we have explicit values selected, use those to determine the stage
|
||||
if (selectedChatId) {
|
||||
setSelectionStage('chat')
|
||||
} else if (selectedChannelId) {
|
||||
setSelectionStage('channel')
|
||||
} else if (selectionType === 'channel' && selectedTeamId) {
|
||||
// If we're in channel mode and have a team selected, go to channel selection
|
||||
setSelectionStage('channel')
|
||||
} else if (selectionType !== 'team' && !selectedTeamId) {
|
||||
// If no selections but we have a specific selection type, use that
|
||||
// But for channel selection, start with team selection if no team is selected
|
||||
if (selectionType === 'channel') {
|
||||
setSelectionStage('team')
|
||||
} else {
|
||||
setSelectionStage(selectionType)
|
||||
}
|
||||
} else {
|
||||
// Default to team selection
|
||||
setSelectionStage('team')
|
||||
}
|
||||
}, [selectedTeamId, selectedChannelId, selectedChatId, selectionType])
|
||||
|
||||
// Handle open change
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
if (disabled || isForeignCredential) {
|
||||
setOpen(false)
|
||||
return
|
||||
}
|
||||
setOpen(isOpen)
|
||||
// Only fetch data when opening the dropdown
|
||||
if (isOpen && selectedCredentialId) {
|
||||
if (selectionStage === 'team') {
|
||||
fetchTeams()
|
||||
} else if (selectionStage === 'channel' && selectedTeamId) {
|
||||
fetchChannels(selectedTeamId)
|
||||
} else if (selectionStage === 'chat') {
|
||||
fetchChats()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keep internal selectedMessageId in sync with the value prop
|
||||
useEffect(() => {
|
||||
if (value !== selectedMessageId) {
|
||||
setSelectedMessageId(value)
|
||||
}
|
||||
}, [value])
|
||||
|
||||
// Handle team selection
|
||||
const handleSelectTeam = (team: TeamsMessageInfo) => {
|
||||
setSelectedTeamId(team.teamId || '')
|
||||
setSelectedChannelId('')
|
||||
setSelectedChatId('')
|
||||
setSelectedMessage(team)
|
||||
setSelectedMessageId(team.id)
|
||||
onChange(team.id, team)
|
||||
onMessageInfoChange?.(team)
|
||||
setSelectionStage('channel')
|
||||
fetchChannels(team.teamId || '')
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
// Handle channel selection
|
||||
const handleSelectChannel = (channel: TeamsMessageInfo) => {
|
||||
setSelectedChannelId(channel.channelId || '')
|
||||
setSelectedChatId('')
|
||||
setSelectedMessage(channel)
|
||||
setSelectedMessageId(channel.channelId || '')
|
||||
onChange(channel.channelId || '', channel)
|
||||
onMessageInfoChange?.(channel)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
// Handle chat selection
|
||||
const handleSelectChat = (chat: TeamsMessageInfo) => {
|
||||
setSelectedChatId(chat.chatId || '')
|
||||
setSelectedMessage(chat)
|
||||
setSelectedMessageId(chat.id)
|
||||
onChange(chat.id, chat)
|
||||
onMessageInfoChange?.(chat)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
// Handle adding a new credential
|
||||
const handleAddCredential = () => {
|
||||
// Show the OAuth modal
|
||||
setShowOAuthModal(true)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
// Clear selection
|
||||
const handleClearSelection = () => {
|
||||
setSelectedMessageId('')
|
||||
setSelectedTeamId('')
|
||||
setSelectedChannelId('')
|
||||
setSelectedChatId('')
|
||||
setSelectedMessage(null)
|
||||
setError(null)
|
||||
onChange('', undefined)
|
||||
onMessageInfoChange?.(null)
|
||||
setSelectionStage(selectionType) // Reset to the initial selection type
|
||||
}
|
||||
|
||||
// Render dropdown options based on the current selection stage
|
||||
const renderSelectionOptions = () => {
|
||||
if (selectionStage === 'team' && teams.length > 0) {
|
||||
return (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>Teams</div>
|
||||
{teams.map((team) => (
|
||||
<CommandItem
|
||||
key={team.id}
|
||||
value={`team-${team.id}-${team.displayName}`}
|
||||
onSelect={() => handleSelectTeam(team)}
|
||||
>
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
<MicrosoftTeamsIcon className='h-4 w-4' />
|
||||
<span className='truncate font-normal'>{team.displayName}</span>
|
||||
</div>
|
||||
{team.teamId === selectedTeamId && <Check className='ml-auto h-4 w-4' />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)
|
||||
}
|
||||
|
||||
if (selectionStage === 'channel' && channels.length > 0) {
|
||||
return (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>Channels</div>
|
||||
{channels.map((channel) => (
|
||||
<CommandItem
|
||||
key={channel.id}
|
||||
value={`channel-${channel.id}-${channel.displayName}`}
|
||||
onSelect={() => handleSelectChannel(channel)}
|
||||
>
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
<MicrosoftTeamsIcon className='h-4 w-4' />
|
||||
<span className='truncate font-normal'>{channel.displayName}</span>
|
||||
</div>
|
||||
{channel.channelId === selectedChannelId && <Check className='ml-auto h-4 w-4' />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)
|
||||
}
|
||||
|
||||
if (selectionStage === 'chat' && chats.length > 0) {
|
||||
return (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>Chats</div>
|
||||
{chats.map((chat) => (
|
||||
<CommandItem
|
||||
key={chat.id}
|
||||
value={`chat-${chat.id}-${chat.displayName}`}
|
||||
onSelect={() => handleSelectChat(chat)}
|
||||
>
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
<MicrosoftTeamsIcon className='h-4 w-4' />
|
||||
<span className='truncate font-normal'>{chat.displayName}</span>
|
||||
</div>
|
||||
{chat.chatId === selectedChatId && <Check className='ml-auto h-4 w-4' />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Restore team selection on page refresh
|
||||
const restoreTeamSelection = useCallback(
|
||||
async (teamId: string) => {
|
||||
if (!selectedCredentialId || !teamId || selectionType !== 'team') return
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const response = await fetch('/api/tools/microsoft-teams/teams', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ credential: selectedCredentialId, workflowId }),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
const team = data.teams.find((t: { id: string; displayName: string }) => t.id === teamId)
|
||||
if (team) {
|
||||
const teamInfo: TeamsMessageInfo = {
|
||||
id: team.id,
|
||||
displayName: team.displayName,
|
||||
type: 'team',
|
||||
teamId: team.id,
|
||||
webViewLink: `https://teams.microsoft.com/l/team/${team.id}`,
|
||||
}
|
||||
setSelectedTeamId(team.id)
|
||||
setSelectedMessage(teamInfo)
|
||||
onMessageInfoChange?.(teamInfo)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error restoring team selection:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
},
|
||||
[selectedCredentialId, selectionType, onMessageInfoChange, workflowId]
|
||||
)
|
||||
|
||||
// Restore chat selection on page refresh
|
||||
const restoreChatSelection = useCallback(
|
||||
async (chatId: string) => {
|
||||
if (!selectedCredentialId || !chatId || selectionType !== 'chat') return
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const response = await fetch('/api/tools/microsoft-teams/chats', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ credential: selectedCredentialId, workflowId }),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
|
||||
// Cache all chat names
|
||||
if (data.chats && selectedCredentialId) {
|
||||
const chatMap = data.chats.reduce(
|
||||
(acc: Record<string, string>, c: { id: string; displayName: string }) => {
|
||||
acc[c.id] = c.displayName
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
useDisplayNamesStore.getState().setDisplayNames('files', selectedCredentialId, chatMap)
|
||||
}
|
||||
|
||||
const chat = data.chats.find((c: { id: string; displayName: string }) => c.id === chatId)
|
||||
if (chat) {
|
||||
const chatInfo: TeamsMessageInfo = {
|
||||
id: chat.id,
|
||||
displayName: chat.displayName,
|
||||
type: 'chat',
|
||||
chatId: chat.id,
|
||||
webViewLink: `https://teams.microsoft.com/l/chat/${chat.id}`,
|
||||
}
|
||||
setSelectedChatId(chat.id)
|
||||
setSelectedMessage(chatInfo)
|
||||
onMessageInfoChange?.(chatInfo)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error restoring chat selection:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
},
|
||||
[selectedCredentialId, selectionType, onMessageInfoChange, workflowId]
|
||||
)
|
||||
|
||||
// Restore channel selection on page refresh
|
||||
const restoreChannelSelection = useCallback(
|
||||
async (channelId: string) => {
|
||||
if (!selectedCredentialId || !channelId || selectionType !== 'channel') return
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
// First fetch teams to search through them
|
||||
const teamsResponse = await fetch('/api/tools/microsoft-teams/teams', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ credential: selectedCredentialId, workflowId }),
|
||||
})
|
||||
|
||||
if (teamsResponse.ok) {
|
||||
const teamsData = await teamsResponse.json()
|
||||
|
||||
// Create parallel promises for all teams to search for the channel
|
||||
const channelSearchPromises = teamsData.teams.map(
|
||||
async (team: { id: string; displayName: string }) => {
|
||||
try {
|
||||
const channelsResponse = await fetch('/api/tools/microsoft-teams/channels', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
credential: selectedCredentialId,
|
||||
teamId: team.id,
|
||||
workflowId,
|
||||
}),
|
||||
})
|
||||
|
||||
if (channelsResponse.ok) {
|
||||
const channelsData = await channelsResponse.json()
|
||||
const channel = channelsData.channels.find(
|
||||
(c: { id: string; displayName: string }) => c.id === channelId
|
||||
)
|
||||
if (channel) {
|
||||
return {
|
||||
team,
|
||||
channel,
|
||||
channelInfo: {
|
||||
id: `${team.id}-${channel.id}`,
|
||||
displayName: channel.displayName,
|
||||
type: 'channel' as const,
|
||||
teamId: team.id,
|
||||
channelId: channel.id,
|
||||
webViewLink: `https://teams.microsoft.com/l/channel/${team.id}/${encodeURIComponent(channel.displayName)}/${channel.id}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`Error searching for channel in team ${team.id}:`,
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
)
|
||||
|
||||
// Wait for all parallel requests to complete (or fail)
|
||||
const results = await Promise.allSettled(channelSearchPromises)
|
||||
|
||||
// Find the first successful result that contains our channel
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled' && result.value) {
|
||||
const { channelInfo } = result.value
|
||||
setSelectedTeamId(channelInfo.teamId!)
|
||||
setSelectedChannelId(channelInfo.channelId!)
|
||||
setSelectedMessage(channelInfo)
|
||||
onMessageInfoChange?.(channelInfo)
|
||||
return // Found the channel, exit successfully
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here, the channel wasn't found in any team
|
||||
logger.warn(`Channel ${channelId} not found in any accessible team`)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error restoring channel selection:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
},
|
||||
[selectedCredentialId, selectionType, onMessageInfoChange, workflowId]
|
||||
)
|
||||
|
||||
// Set initial team ID if provided
|
||||
useEffect(() => {
|
||||
if (initialTeamId && !selectedTeamId && selectionType === 'channel') {
|
||||
setSelectedTeamId(initialTeamId)
|
||||
}
|
||||
}, [initialTeamId, selectedTeamId, selectionType])
|
||||
|
||||
// Clear selection when selectionType changes to allow proper restoration
|
||||
useEffect(() => {
|
||||
setSelectedMessage(null)
|
||||
setSelectedTeamId('')
|
||||
setSelectedChannelId('')
|
||||
setSelectedChatId('')
|
||||
}, [selectionType])
|
||||
|
||||
// Fetch appropriate data on initial mount based on selectionType
|
||||
useEffect(() => {
|
||||
if (!initialFetchRef.current) {
|
||||
fetchCredentials()
|
||||
initialFetchRef.current = true
|
||||
}
|
||||
}, [fetchCredentials])
|
||||
|
||||
// Keep local credential state in sync with persisted credential
|
||||
useEffect(() => {
|
||||
if (credential && credential !== selectedCredentialId) {
|
||||
setSelectedCredentialId(credential)
|
||||
}
|
||||
}, [credential, selectedCredentialId])
|
||||
|
||||
// Restore selection whenever the canonical value changes
|
||||
useEffect(() => {
|
||||
if (value && selectedCredentialId) {
|
||||
// Only restore if we haven't already restored this value
|
||||
if (lastRestoredValueRef.current !== value) {
|
||||
lastRestoredValueRef.current = value
|
||||
|
||||
if (selectionType === 'team') {
|
||||
restoreTeamSelection(value)
|
||||
} else if (selectionType === 'chat') {
|
||||
restoreChatSelection(value)
|
||||
} else if (selectionType === 'channel') {
|
||||
restoreChannelSelection(value)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
lastRestoredValueRef.current = null
|
||||
setSelectedMessage(null)
|
||||
}
|
||||
}, [
|
||||
value,
|
||||
selectedCredentialId,
|
||||
selectionType,
|
||||
restoreTeamSelection,
|
||||
restoreChatSelection,
|
||||
restoreChannelSelection,
|
||||
])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-2'>
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
role='combobox'
|
||||
aria-expanded={open}
|
||||
className='h-10 w-full min-w-0 justify-between'
|
||||
disabled={disabled || isForeignCredential}
|
||||
>
|
||||
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
|
||||
{cachedMessageName ? (
|
||||
<>
|
||||
<MicrosoftTeamsIcon className='h-4 w-4' />
|
||||
<span className='truncate font-normal'>{cachedMessageName}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MicrosoftTeamsIcon className='h-4 w-4' />
|
||||
<span className='truncate text-muted-foreground'>
|
||||
{selectionType === 'channel' && selectionStage === 'team'
|
||||
? 'Select a team first'
|
||||
: label}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
{!isForeignCredential && (
|
||||
<PopoverContent className='w-[300px] p-0' align='start'>
|
||||
{/* Current account indicator */}
|
||||
{selectedCredentialId && credentials.length > 0 && (
|
||||
<div className='flex items-center justify-between border-b px-3 py-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<MicrosoftTeamsIcon className='h-4 w-4' />
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
|
||||
'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
{credentials.length > 1 && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 px-2 text-xs'
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Switch
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Command>
|
||||
<CommandInput placeholder={`Search ${selectionStage}s...`} />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{isLoading ? (
|
||||
<div className='flex items-center justify-center p-4'>
|
||||
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||
<span className='ml-2'>Loading {selectionStage}s...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='text-destructive text-sm'>{error}</p>
|
||||
{selectionStage === 'chat' && error.includes('teams') && (
|
||||
<p className='mt-1 text-muted-foreground text-xs'>
|
||||
There was an issue fetching chats. Please try again or connect a
|
||||
different account.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : credentials.length === 0 ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No accounts connected.</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Connect a Microsoft Teams account to{' '}
|
||||
{selectionStage === 'chat'
|
||||
? 'access your chats'
|
||||
: selectionStage === 'channel'
|
||||
? 'see your channels'
|
||||
: 'continue'}
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No {selectionStage}s found.</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{selectionStage === 'team'
|
||||
? 'Try a different account.'
|
||||
: selectionStage === 'channel'
|
||||
? selectedTeamId
|
||||
? 'This team has no channels or you may not have access.'
|
||||
: 'Please select a team first to see its channels.'
|
||||
: 'Try a different account or check if you have any active chats.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CommandEmpty>
|
||||
|
||||
{/* Account selection - only show if we have multiple accounts */}
|
||||
{credentials.length > 1 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
Switch Account
|
||||
</div>
|
||||
{credentials.map((cred) => (
|
||||
<CommandItem
|
||||
key={cred.id}
|
||||
value={`account-${cred.id}`}
|
||||
onSelect={() => {
|
||||
setSelectedCredentialId(cred.id)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<MicrosoftTeamsIcon className='h-4 w-4' />
|
||||
<span className='font-normal'>{cred.name}</span>
|
||||
</div>
|
||||
{cred.id === selectedCredentialId && (
|
||||
<Check className='ml-auto h-4 w-4' />
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Display appropriate options based on selection stage */}
|
||||
{renderSelectionOptions()}
|
||||
|
||||
{/* Connect account option - only show if no credentials */}
|
||||
{credentials.length === 0 && (
|
||||
<CommandGroup>
|
||||
<CommandItem onSelect={handleAddCredential}>
|
||||
<div className='flex items-center gap-2 text-foreground'>
|
||||
<MicrosoftTeamsIcon className='h-4 w-4' />
|
||||
<span>Connect Microsoft Teams account</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
)}
|
||||
</Popover>
|
||||
|
||||
{/* Selection preview */}
|
||||
{showPreview && selectedMessage && (
|
||||
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
|
||||
<div className='absolute top-2 right-2'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-5 w-5 hover:bg-muted'
|
||||
onClick={handleClearSelection}
|
||||
>
|
||||
<X className='h-3 w-3' />
|
||||
</Button>
|
||||
</div>
|
||||
<div className='flex items-center gap-3 pr-4'>
|
||||
<div className='flex h-6 w-6 flex-shrink-0 items-center justify-center rounded bg-muted/20'>
|
||||
<MicrosoftTeamsIcon className='h-4 w-4' />
|
||||
</div>
|
||||
<div className='min-w-0 flex-1 overflow-hidden'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<h4 className='truncate font-medium text-xs'>{selectedMessage.displayName}</h4>
|
||||
<span className='whitespace-nowrap text-muted-foreground text-xs'>
|
||||
{selectedMessage.type}
|
||||
</span>
|
||||
</div>
|
||||
{selectedMessage.webViewLink ? (
|
||||
<a
|
||||
href={selectedMessage.webViewLink}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='flex items-center gap-1 text-foreground text-xs hover:underline'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span>Open in Microsoft Teams</span>
|
||||
<ExternalLink className='h-3 w-3' />
|
||||
</a>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showOAuthModal && (
|
||||
<OAuthRequiredModal
|
||||
isOpen={showOAuthModal}
|
||||
onClose={() => setShowOAuthModal(false)}
|
||||
provider={provider}
|
||||
toolName='Microsoft Teams'
|
||||
requiredScopes={requiredScopes}
|
||||
serviceId={getServiceId()}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,484 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Check, ChevronDown, X } from 'lucide-react'
|
||||
import { WealthboxIcon } from '@/components/icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
type Credential,
|
||||
getProviderIdFromServiceId,
|
||||
getServiceIdFromScopes,
|
||||
type OAuthProvider,
|
||||
} from '@/lib/oauth'
|
||||
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
|
||||
import { useDisplayNamesStore } from '@/stores/display-names/store'
|
||||
|
||||
const logger = createLogger('WealthboxFileSelector')
|
||||
|
||||
export interface WealthboxItemInfo {
|
||||
id: string
|
||||
name: string
|
||||
type: 'contact'
|
||||
content?: string
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
interface WealthboxFileSelectorProps {
|
||||
value: string
|
||||
onChange: (value: string, itemInfo?: WealthboxItemInfo) => void
|
||||
provider: OAuthProvider
|
||||
requiredScopes?: string[]
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
serviceId?: string
|
||||
showPreview?: boolean
|
||||
onFileInfoChange?: (itemInfo: WealthboxItemInfo | null) => void
|
||||
itemType?: 'contact'
|
||||
credentialId?: string
|
||||
}
|
||||
|
||||
export function WealthboxFileSelector({
|
||||
value,
|
||||
onChange,
|
||||
provider,
|
||||
requiredScopes = [],
|
||||
label = 'Select item',
|
||||
disabled = false,
|
||||
serviceId,
|
||||
showPreview = true,
|
||||
onFileInfoChange,
|
||||
itemType = 'contact',
|
||||
credentialId,
|
||||
}: WealthboxFileSelectorProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [credentials, setCredentials] = useState<Credential[]>([])
|
||||
const [selectedCredentialId, setSelectedCredentialId] = useState<string>(credentialId || '')
|
||||
const [selectedItemId, setSelectedItemId] = useState(value)
|
||||
const [selectedItem, setSelectedItem] = useState<WealthboxItemInfo | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isLoadingSelectedItem, setIsLoadingSelectedItem] = useState(false)
|
||||
const [isLoadingItems, setIsLoadingItems] = useState(false)
|
||||
const [availableItems, setAvailableItems] = useState<WealthboxItemInfo[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState<string>('')
|
||||
const [showOAuthModal, setShowOAuthModal] = useState(false)
|
||||
const [credentialsLoaded, setCredentialsLoaded] = useState(false)
|
||||
const initialFetchRef = useRef(false)
|
||||
|
||||
// Get cached display name
|
||||
const cachedItemName = useDisplayNamesStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
const effectiveCredentialId = credentialId || selectedCredentialId
|
||||
if (!effectiveCredentialId || !value) return null
|
||||
return state.cache.files[effectiveCredentialId]?.[value] || null
|
||||
},
|
||||
[credentialId, selectedCredentialId, value]
|
||||
)
|
||||
)
|
||||
|
||||
// Determine the appropriate service ID based on provider and scopes
|
||||
const getServiceId = (): string => {
|
||||
if (serviceId) return serviceId
|
||||
return getServiceIdFromScopes(provider, requiredScopes)
|
||||
}
|
||||
|
||||
// Determine the appropriate provider ID based on service and scopes
|
||||
const getProviderId = (): string => {
|
||||
const effectiveServiceId = getServiceId()
|
||||
return getProviderIdFromServiceId(effectiveServiceId)
|
||||
}
|
||||
|
||||
// Fetch available credentials for this provider
|
||||
const fetchCredentials = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
setCredentialsLoaded(false)
|
||||
try {
|
||||
const providerId = getProviderId()
|
||||
const response = await fetch(`/api/auth/oauth/credentials?provider=${providerId}`)
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setCredentials(data.credentials)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching credentials:', { error })
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
setCredentialsLoaded(true)
|
||||
}
|
||||
}, [provider, getProviderId, selectedCredentialId])
|
||||
|
||||
// Keep local credential state in sync with persisted credential
|
||||
useEffect(() => {
|
||||
if (credentialId && credentialId !== selectedCredentialId) {
|
||||
setSelectedCredentialId(credentialId)
|
||||
}
|
||||
}, [credentialId, selectedCredentialId])
|
||||
|
||||
// Debounced search function
|
||||
const [searchTimeout, setSearchTimeout] = useState<NodeJS.Timeout | null>(null)
|
||||
|
||||
// Fetch available items for the selected credential
|
||||
const fetchAvailableItems = useCallback(async () => {
|
||||
if (!selectedCredentialId) return
|
||||
|
||||
setIsLoadingItems(true)
|
||||
try {
|
||||
const queryParams = new URLSearchParams({
|
||||
credentialId: selectedCredentialId,
|
||||
type: itemType,
|
||||
})
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
queryParams.append('query', searchQuery.trim())
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/auth/oauth/wealthbox/items?${queryParams.toString()}`)
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setAvailableItems(data.items || [])
|
||||
|
||||
// Cache item names in display names store
|
||||
if (selectedCredentialId && data.items) {
|
||||
const itemMap = data.items.reduce(
|
||||
(acc: Record<string, string>, item: WealthboxItemInfo) => {
|
||||
acc[item.id] = item.name
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
useDisplayNamesStore.getState().setDisplayNames('files', selectedCredentialId, itemMap)
|
||||
}
|
||||
} else {
|
||||
logger.error('Error fetching available items:', {
|
||||
error: await response.text(),
|
||||
})
|
||||
setAvailableItems([])
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching available items:', { error })
|
||||
setAvailableItems([])
|
||||
} finally {
|
||||
setIsLoadingItems(false)
|
||||
}
|
||||
}, [selectedCredentialId, searchQuery, itemType])
|
||||
|
||||
// Fetch a single item by ID
|
||||
const fetchItemById = useCallback(
|
||||
async (itemId: string) => {
|
||||
if (!selectedCredentialId || !itemId) return null
|
||||
|
||||
setIsLoadingSelectedItem(true)
|
||||
try {
|
||||
const queryParams = new URLSearchParams({
|
||||
credentialId: selectedCredentialId,
|
||||
itemId: itemId,
|
||||
type: itemType,
|
||||
})
|
||||
|
||||
const response = await fetch(`/api/auth/oauth/wealthbox/item?${queryParams.toString()}`)
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
if (data.item) {
|
||||
setSelectedItem(data.item)
|
||||
onFileInfoChange?.(data.item)
|
||||
|
||||
// Cache the item name in display names store
|
||||
if (selectedCredentialId) {
|
||||
useDisplayNamesStore
|
||||
.getState()
|
||||
.setDisplayNames('files', selectedCredentialId, { [data.item.id]: data.item.name })
|
||||
}
|
||||
|
||||
return data.item
|
||||
}
|
||||
} else {
|
||||
const errorText = await response.text()
|
||||
logger.error('Error fetching item by ID:', { error: errorText })
|
||||
|
||||
if (response.status === 404 || response.status === 403) {
|
||||
logger.info('Item not accessible, clearing selection')
|
||||
setSelectedItemId('')
|
||||
onChange('')
|
||||
onFileInfoChange?.(null)
|
||||
}
|
||||
}
|
||||
return null
|
||||
} catch (error) {
|
||||
logger.error('Error fetching item by ID:', { error })
|
||||
return null
|
||||
} finally {
|
||||
setIsLoadingSelectedItem(false)
|
||||
}
|
||||
},
|
||||
[selectedCredentialId, itemType, onFileInfoChange, onChange]
|
||||
)
|
||||
|
||||
// Fetch credentials on initial mount
|
||||
useEffect(() => {
|
||||
if (!initialFetchRef.current) {
|
||||
fetchCredentials()
|
||||
initialFetchRef.current = true
|
||||
}
|
||||
}, [fetchCredentials])
|
||||
|
||||
// Fetch available items only when dropdown is opened
|
||||
useEffect(() => {
|
||||
if (selectedCredentialId && open) {
|
||||
fetchAvailableItems()
|
||||
}
|
||||
}, [selectedCredentialId, open, fetchAvailableItems])
|
||||
|
||||
// Fetch item info on mount if we have a value but no selectedItem state
|
||||
useEffect(() => {
|
||||
if (value && selectedCredentialId && !selectedItem) {
|
||||
fetchItemById(value)
|
||||
}
|
||||
}, [value, selectedCredentialId, selectedItem, fetchItemById])
|
||||
|
||||
// Clear selectedItem when value is cleared
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
setSelectedItem(null)
|
||||
onFileInfoChange?.(null)
|
||||
}
|
||||
}, [value, onFileInfoChange])
|
||||
|
||||
// Handle search input changes with debouncing
|
||||
const handleSearchChange = useCallback(
|
||||
(newQuery: string) => {
|
||||
setSearchQuery(newQuery)
|
||||
|
||||
// Clear existing timeout
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout)
|
||||
}
|
||||
|
||||
// Set new timeout for search
|
||||
const timeout = setTimeout(() => {
|
||||
if (selectedCredentialId) {
|
||||
fetchAvailableItems()
|
||||
}
|
||||
}, 300) // 300ms debounce
|
||||
|
||||
setSearchTimeout(timeout)
|
||||
},
|
||||
[selectedCredentialId, fetchAvailableItems, searchTimeout]
|
||||
)
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout)
|
||||
}
|
||||
}
|
||||
}, [searchTimeout])
|
||||
|
||||
// Handle selecting an item
|
||||
const handleItemSelect = (item: WealthboxItemInfo) => {
|
||||
setSelectedItemId(item.id)
|
||||
setSelectedItem(item)
|
||||
onChange(item.id, item)
|
||||
onFileInfoChange?.(item)
|
||||
setOpen(false)
|
||||
setSearchQuery('')
|
||||
}
|
||||
|
||||
// Handle adding a new credential
|
||||
const handleAddCredential = () => {
|
||||
setShowOAuthModal(true)
|
||||
setOpen(false)
|
||||
setSearchQuery('')
|
||||
}
|
||||
|
||||
// Clear selection
|
||||
const handleClearSelection = () => {
|
||||
setSelectedItemId('')
|
||||
onChange('', undefined)
|
||||
onFileInfoChange?.(null)
|
||||
}
|
||||
|
||||
const getItemTypeLabel = () => {
|
||||
switch (itemType) {
|
||||
case 'contact':
|
||||
return 'Contacts'
|
||||
default:
|
||||
return 'Contacts'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-2'>
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
setOpen(isOpen)
|
||||
if (!isOpen) {
|
||||
setSearchQuery('')
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout)
|
||||
setSearchTimeout(null)
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
role='combobox'
|
||||
aria-expanded={open}
|
||||
className='w-full justify-between'
|
||||
disabled={disabled}
|
||||
>
|
||||
{cachedItemName ? (
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
<WealthboxIcon className='h-4 w-4' />
|
||||
<span className='truncate font-normal'>{cachedItemName}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex items-center gap-2'>
|
||||
<WealthboxIcon className='h-4 w-4' />
|
||||
<span className='text-muted-foreground'>{label}</span>
|
||||
</div>
|
||||
)}
|
||||
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-[300px] p-0' align='start'>
|
||||
<Command shouldFilter={false}>
|
||||
<div className='flex items-center border-b px-3' cmdk-input-wrapper=''>
|
||||
<input
|
||||
placeholder={`Search ${itemType}s...`}
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
className='flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50'
|
||||
/>
|
||||
</div>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{isLoadingItems ? `Loading ${itemType}s...` : `No ${itemType}s found.`}
|
||||
</CommandEmpty>
|
||||
|
||||
{credentials.length > 1 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
Switch Account
|
||||
</div>
|
||||
{credentials.map((cred) => (
|
||||
<CommandItem
|
||||
key={cred.id}
|
||||
value={`account-${cred.id}`}
|
||||
onSelect={() => setSelectedCredentialId(cred.id)}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<WealthboxIcon className='h-4 w-4' />
|
||||
<span className='font-normal'>{cred.name}</span>
|
||||
</div>
|
||||
{cred.id === selectedCredentialId && <Check className='ml-auto h-4 w-4' />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{availableItems.length > 0 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
{getItemTypeLabel()}
|
||||
</div>
|
||||
{availableItems.map((item) => (
|
||||
<CommandItem
|
||||
key={item.id}
|
||||
value={`item-${item.id}-${item.name}`}
|
||||
onSelect={() => handleItemSelect(item)}
|
||||
>
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
<WealthboxIcon className='h-4 w-4' />
|
||||
<div className='min-w-0 flex-1'>
|
||||
<span className='truncate font-normal'>{item.name}</span>
|
||||
{item.updatedAt && (
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
Updated {new Date(item.updatedAt).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{item.id === selectedItemId && <Check className='ml-auto h-4 w-4' />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{credentials.length === 0 && (
|
||||
<CommandGroup>
|
||||
<CommandItem onSelect={handleAddCredential}>
|
||||
<div className='flex items-center gap-2 text-foreground'>
|
||||
<WealthboxIcon className='h-4 w-4' />
|
||||
<span>Connect Wealthbox account</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{showPreview && selectedItem && (
|
||||
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
|
||||
<div className='absolute top-2 right-2'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-5 w-5 hover:bg-muted'
|
||||
onClick={handleClearSelection}
|
||||
>
|
||||
<X className='h-3 w-3' />
|
||||
</Button>
|
||||
</div>
|
||||
<div className='flex items-center gap-3 pr-4'>
|
||||
<div className='flex h-6 w-6 flex-shrink-0 items-center justify-center rounded bg-muted/20'>
|
||||
<WealthboxIcon className='h-4 w-4' />
|
||||
</div>
|
||||
<div className='min-w-0 flex-1 overflow-hidden'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<h4 className='truncate font-medium text-xs'>{selectedItem.name}</h4>
|
||||
{selectedItem.updatedAt && (
|
||||
<span className='whitespace-nowrap text-muted-foreground text-xs'>
|
||||
{new Date(selectedItem.updatedAt).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className='text-muted-foreground text-xs capitalize'>{selectedItem.type}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showOAuthModal && (
|
||||
<OAuthRequiredModal
|
||||
isOpen={showOAuthModal}
|
||||
onClose={() => setShowOAuthModal(false)}
|
||||
toolName='Wealthbox'
|
||||
provider={provider}
|
||||
requiredScopes={requiredScopes}
|
||||
serviceId={getServiceId()}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,22 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Tooltip } from '@/components/emcn'
|
||||
import { getEnv } from '@/lib/env'
|
||||
import { getProviderIdFromServiceId } from '@/lib/oauth'
|
||||
import {
|
||||
ConfluenceFileSelector,
|
||||
GoogleCalendarSelector,
|
||||
GoogleDrivePicker,
|
||||
JiraIssueSelector,
|
||||
MicrosoftFileSelector,
|
||||
TeamsMessageSelector,
|
||||
WealthboxFileSelector,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/file-selector/components'
|
||||
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
|
||||
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-depends-on-gate'
|
||||
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-foreign-credential'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { resolveSelectorForSubBlock, type SelectorResolution } from '@/hooks/selectors/resolution'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
@@ -41,506 +33,108 @@ export function FileSelectorInput({
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
const params = useParams()
|
||||
const workflowIdFromUrl = (params?.workflowId as string) || activeWorkflowId || ''
|
||||
// Central dependsOn gating for this selector instance
|
||||
const { finalDisabled, dependsOn } = useDependsOnGate(blockId, subBlock, {
|
||||
|
||||
const { finalDisabled } = useDependsOnGate(blockId, subBlock, {
|
||||
disabled,
|
||||
isPreview,
|
||||
previewContextValues,
|
||||
})
|
||||
|
||||
// Helper to coerce various preview value shapes into a string ID
|
||||
const coerceToIdString = (val: unknown): string => {
|
||||
if (!val) return ''
|
||||
if (typeof val === 'string') return val
|
||||
if (typeof val === 'number') return String(val)
|
||||
if (typeof val === 'object') {
|
||||
const obj = val as Record<string, any>
|
||||
return (obj.id ||
|
||||
obj.fileId ||
|
||||
obj.value ||
|
||||
obj.documentId ||
|
||||
obj.spreadsheetId ||
|
||||
'') as string
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
// Use the proper hook to get the current value and setter
|
||||
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
|
||||
const [connectedCredentialFromStore] = useSubBlockValue(blockId, 'credential')
|
||||
const [domainValueFromStore] = useSubBlockValue(blockId, 'domain')
|
||||
const [projectIdValueFromStore] = useSubBlockValue(blockId, 'projectId')
|
||||
const [planIdValueFromStore] = useSubBlockValue(blockId, 'planId')
|
||||
const [teamIdValueFromStore] = useSubBlockValue(blockId, 'teamId')
|
||||
const [operationValueFromStore] = useSubBlockValue(blockId, 'operation')
|
||||
|
||||
// Use previewContextValues if provided (for tools inside agent blocks), otherwise use store values
|
||||
const connectedCredential = previewContextValues?.credential ?? connectedCredentialFromStore
|
||||
const domainValue = previewContextValues?.domain ?? domainValueFromStore
|
||||
const projectIdValue = previewContextValues?.projectId ?? projectIdValueFromStore
|
||||
const planIdValue = previewContextValues?.planId ?? planIdValueFromStore
|
||||
const teamIdValue = previewContextValues?.teamId ?? teamIdValueFromStore
|
||||
const operationValue = previewContextValues?.operation ?? operationValueFromStore
|
||||
|
||||
// Determine if the persisted credential belongs to the current viewer
|
||||
// Use service providerId where available (e.g., onedrive/sharepoint) instead of base provider ("microsoft")
|
||||
const foreignCheckProvider = subBlock.serviceId
|
||||
? getProviderIdFromServiceId(subBlock.serviceId)
|
||||
: (subBlock.provider as string) || ''
|
||||
const normalizedCredentialId = coerceToIdString(connectedCredential)
|
||||
const providerForForeignCheck = foreignCheckProvider || (subBlock.provider as string) || undefined
|
||||
const normalizedCredentialId =
|
||||
typeof connectedCredential === 'string'
|
||||
? connectedCredential
|
||||
: typeof connectedCredential === 'object' && connectedCredential !== null
|
||||
? ((connectedCredential as Record<string, any>).id ?? '')
|
||||
: ''
|
||||
|
||||
const { isForeignCredential } = useForeignCredential(
|
||||
providerForForeignCheck,
|
||||
subBlock.serviceId || subBlock.provider,
|
||||
normalizedCredentialId
|
||||
)
|
||||
|
||||
// Get provider-specific values
|
||||
const provider = subBlock.provider || 'google-drive'
|
||||
const isConfluence = provider === 'confluence'
|
||||
const isJira = provider === 'jira'
|
||||
const isMicrosoftTeams = provider === 'microsoft-teams'
|
||||
const isMicrosoftExcel = provider === 'microsoft-excel'
|
||||
const isMicrosoftWord = provider === 'microsoft-word'
|
||||
const isMicrosoftOneDrive = provider === 'microsoft' && subBlock.serviceId === 'onedrive'
|
||||
const isGoogleCalendar = subBlock.provider === 'google-calendar'
|
||||
const isWealthbox = provider === 'wealthbox'
|
||||
const isMicrosoftSharePoint = provider === 'microsoft' && subBlock.serviceId === 'sharepoint'
|
||||
const isMicrosoftPlanner = provider === 'microsoft-planner'
|
||||
const selectorResolution = useMemo<SelectorResolution | null>(() => {
|
||||
return resolveSelectorForSubBlock(subBlock, {
|
||||
workflowId: workflowIdFromUrl,
|
||||
credentialId: normalizedCredentialId,
|
||||
domain: (domainValue as string) || undefined,
|
||||
projectId: (projectIdValue as string) || undefined,
|
||||
planId: (planIdValue as string) || undefined,
|
||||
teamId: (teamIdValue as string) || undefined,
|
||||
})
|
||||
}, [
|
||||
subBlock,
|
||||
workflowIdFromUrl,
|
||||
normalizedCredentialId,
|
||||
domainValue,
|
||||
projectIdValue,
|
||||
planIdValue,
|
||||
teamIdValue,
|
||||
])
|
||||
|
||||
// For Confluence and Jira, we need the domain and credentials
|
||||
const domain =
|
||||
isConfluence || isJira
|
||||
? (isPreview && previewContextValues?.domain?.value) || (domainValue as string) || ''
|
||||
: ''
|
||||
const jiraCredential = isJira
|
||||
? (isPreview && previewContextValues?.credential?.value) ||
|
||||
(connectedCredential as string) ||
|
||||
''
|
||||
: ''
|
||||
const missingCredential = !normalizedCredentialId
|
||||
const missingDomain =
|
||||
selectorResolution?.key &&
|
||||
(selectorResolution.key === 'confluence.pages' || selectorResolution.key === 'jira.issues') &&
|
||||
!selectorResolution.context.domain
|
||||
const missingProject =
|
||||
selectorResolution?.key === 'jira.issues' &&
|
||||
subBlock.dependsOn?.includes('projectId') &&
|
||||
!selectorResolution.context.projectId
|
||||
const missingPlan =
|
||||
selectorResolution?.key === 'microsoft.planner' && !selectorResolution.context.planId
|
||||
|
||||
// Discord channel selector removed; no special values used here
|
||||
|
||||
// Use preview value when in preview mode, otherwise use store value
|
||||
const value = isPreview ? previewValue : storeValue
|
||||
|
||||
const credentialDependencySatisfied = (() => {
|
||||
if (!dependsOn.includes('credential')) return true
|
||||
if (!normalizedCredentialId || normalizedCredentialId.trim().length === 0) {
|
||||
return false
|
||||
}
|
||||
if (isForeignCredential) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})()
|
||||
|
||||
const shouldForceDisable = !credentialDependencySatisfied
|
||||
|
||||
// For Google Drive
|
||||
const clientId = getEnv('NEXT_PUBLIC_GOOGLE_CLIENT_ID') || ''
|
||||
const apiKey = getEnv('NEXT_PUBLIC_GOOGLE_API_KEY') || ''
|
||||
|
||||
// Render Google Calendar selector
|
||||
if (isGoogleCalendar) {
|
||||
const credential = (connectedCredential as string) || ''
|
||||
const disabledReason =
|
||||
finalDisabled ||
|
||||
isForeignCredential ||
|
||||
missingCredential ||
|
||||
missingDomain ||
|
||||
missingProject ||
|
||||
missingPlan ||
|
||||
!selectorResolution?.key
|
||||
|
||||
if (!selectorResolution?.key) {
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div className='w-full'>
|
||||
<GoogleCalendarSelector
|
||||
value={
|
||||
(isPreview && previewValue !== undefined
|
||||
? (previewValue as string)
|
||||
: (storeValue as string)) || ''
|
||||
}
|
||||
onChange={(val: string) => {
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, val)
|
||||
}}
|
||||
label={subBlock.placeholder || 'Select Google Calendar'}
|
||||
disabled={finalDisabled || shouldForceDisable}
|
||||
showPreview={true}
|
||||
credentialId={credential}
|
||||
workflowId={workflowIdFromUrl}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div className='w-full rounded border border-dashed p-4 text-center text-muted-foreground text-sm'>
|
||||
File selector not supported for provider: {subBlock.provider || subBlock.serviceId}
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
<p>This file selector is not implemented for {subBlock.provider || subBlock.serviceId}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)
|
||||
}
|
||||
|
||||
// Render the appropriate picker based on provider
|
||||
if (isConfluence) {
|
||||
const credential = (connectedCredential as string) || ''
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div className='w-full'>
|
||||
<ConfluenceFileSelector
|
||||
value={
|
||||
(isPreview && previewValue !== undefined
|
||||
? (previewValue as string)
|
||||
: (storeValue as string)) || ''
|
||||
}
|
||||
onChange={(val) => {
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, val)
|
||||
}}
|
||||
domain={domain}
|
||||
provider='confluence'
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
serviceId={subBlock.serviceId}
|
||||
label={subBlock.placeholder || 'Select Confluence page'}
|
||||
disabled={finalDisabled || shouldForceDisable}
|
||||
showPreview={true}
|
||||
credentialId={credential}
|
||||
workflowId={workflowIdFromUrl}
|
||||
isForeignCredential={isForeignCredential}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
if (isJira) {
|
||||
const credential = (connectedCredential as string) || ''
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div className='w-full'>
|
||||
<JiraIssueSelector
|
||||
value={
|
||||
(isPreview && previewValue !== undefined
|
||||
? (previewValue as string)
|
||||
: (storeValue as string)) || ''
|
||||
}
|
||||
onChange={(issueKey) => {
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, issueKey)
|
||||
}}
|
||||
domain={domain}
|
||||
provider='jira'
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
serviceId={subBlock.serviceId}
|
||||
label={subBlock.placeholder || 'Select Jira issue'}
|
||||
disabled={finalDisabled || shouldForceDisable}
|
||||
showPreview={true}
|
||||
credentialId={credential}
|
||||
projectId={(projectIdValue as string) || ''}
|
||||
isForeignCredential={isForeignCredential}
|
||||
workflowId={activeWorkflowId || ''}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
if (isMicrosoftExcel) {
|
||||
const credential = (connectedCredential as string) || ''
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div className='w-full'>
|
||||
<MicrosoftFileSelector
|
||||
value={coerceToIdString(
|
||||
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
|
||||
)}
|
||||
onChange={(fileId) => setStoreValue(fileId)}
|
||||
provider='microsoft-excel'
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
serviceId={subBlock.serviceId}
|
||||
label={subBlock.placeholder || 'Select Microsoft Excel file'}
|
||||
disabled={finalDisabled || shouldForceDisable}
|
||||
showPreview={true}
|
||||
workflowId={activeWorkflowId || ''}
|
||||
credentialId={credential}
|
||||
isForeignCredential={isForeignCredential}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// Microsoft Word selector
|
||||
if (isMicrosoftWord) {
|
||||
const credential = (connectedCredential as string) || ''
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div className='w-full'>
|
||||
<MicrosoftFileSelector
|
||||
value={coerceToIdString(
|
||||
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
|
||||
)}
|
||||
onChange={(fileId) => setStoreValue(fileId)}
|
||||
provider='microsoft-word'
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
serviceId={subBlock.serviceId}
|
||||
label={subBlock.placeholder || 'Select Microsoft Word document'}
|
||||
disabled={finalDisabled || shouldForceDisable}
|
||||
showPreview={true}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// Microsoft OneDrive selector
|
||||
if (isMicrosoftOneDrive) {
|
||||
const credential = (connectedCredential as string) || ''
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div className='w-full'>
|
||||
<MicrosoftFileSelector
|
||||
value={coerceToIdString(
|
||||
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
|
||||
)}
|
||||
onChange={(fileId) => setStoreValue(fileId)}
|
||||
provider='microsoft'
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
serviceId={subBlock.serviceId}
|
||||
mimeType={subBlock.mimeType}
|
||||
label={subBlock.placeholder || 'Select OneDrive folder'}
|
||||
disabled={finalDisabled || shouldForceDisable}
|
||||
showPreview={true}
|
||||
workflowId={activeWorkflowId || ''}
|
||||
credentialId={credential}
|
||||
isForeignCredential={isForeignCredential}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// Microsoft SharePoint selector
|
||||
if (isMicrosoftSharePoint) {
|
||||
const credential = (connectedCredential as string) || ''
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div className='w-full'>
|
||||
<MicrosoftFileSelector
|
||||
value={coerceToIdString(
|
||||
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
|
||||
)}
|
||||
onChange={(fileId) => setStoreValue(fileId)}
|
||||
provider='microsoft'
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
serviceId={subBlock.serviceId}
|
||||
label={subBlock.placeholder || 'Select SharePoint site'}
|
||||
disabled={finalDisabled || shouldForceDisable}
|
||||
showPreview={true}
|
||||
workflowId={activeWorkflowId || ''}
|
||||
credentialId={credential}
|
||||
isForeignCredential={isForeignCredential}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
{!credential && (
|
||||
<Tooltip.Content side='top'>
|
||||
<p>Please select SharePoint credentials first</p>
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// Microsoft Planner task selector
|
||||
if (isMicrosoftPlanner) {
|
||||
const credential = (connectedCredential as string) || ''
|
||||
const planId = (planIdValue as string) || ''
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div className='w-full'>
|
||||
<MicrosoftFileSelector
|
||||
value={coerceToIdString(
|
||||
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
|
||||
)}
|
||||
onChange={(fileId) => setStoreValue(fileId)}
|
||||
provider='microsoft-planner'
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
serviceId='microsoft-planner'
|
||||
label={subBlock.placeholder || 'Select task'}
|
||||
disabled={finalDisabled || shouldForceDisable}
|
||||
showPreview={true}
|
||||
planId={planId}
|
||||
workflowId={activeWorkflowId || ''}
|
||||
credentialId={credential}
|
||||
isForeignCredential={isForeignCredential}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
{!credential ? (
|
||||
<Tooltip.Content side='top'>
|
||||
<p>Please select Microsoft Planner credentials first</p>
|
||||
</Tooltip.Content>
|
||||
) : !planId ? (
|
||||
<Tooltip.Content side='top'>
|
||||
<p>Please enter a Plan ID first</p>
|
||||
</Tooltip.Content>
|
||||
) : null}
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// Microsoft Teams selector
|
||||
if (isMicrosoftTeams) {
|
||||
const credential = (connectedCredential as string) || ''
|
||||
|
||||
// Determine the selector type based on the subBlock ID / operation
|
||||
let selectionType: 'team' | 'channel' | 'chat' = 'team'
|
||||
if (subBlock.id === 'teamId') selectionType = 'team'
|
||||
else if (subBlock.id === 'channelId') selectionType = 'channel'
|
||||
else if (subBlock.id === 'chatId') selectionType = 'chat'
|
||||
else {
|
||||
const operation = (operationValue as string) || ''
|
||||
if (operation.includes('chat')) selectionType = 'chat'
|
||||
else if (operation.includes('channel')) selectionType = 'channel'
|
||||
}
|
||||
|
||||
const selectedTeamId = (teamIdValue as string) || ''
|
||||
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div className='w-full'>
|
||||
<TeamsMessageSelector
|
||||
value={
|
||||
(isPreview && previewValue !== undefined
|
||||
? (previewValue as string)
|
||||
: (storeValue as string)) || ''
|
||||
}
|
||||
onChange={(val) => {
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, val)
|
||||
}}
|
||||
provider='microsoft-teams'
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
serviceId={subBlock.serviceId}
|
||||
label={subBlock.placeholder || 'Select Teams message location'}
|
||||
disabled={finalDisabled || shouldForceDisable}
|
||||
showPreview={true}
|
||||
credential={credential}
|
||||
selectionType={selectionType}
|
||||
initialTeamId={selectedTeamId}
|
||||
workflowId={activeWorkflowId || ''}
|
||||
isForeignCredential={isForeignCredential}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
{!credential && (
|
||||
<Tooltip.Content side='top'>
|
||||
<p>Please select Microsoft Teams credentials first</p>
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// Wealthbox selector
|
||||
if (isWealthbox) {
|
||||
const credential = (connectedCredential as string) || ''
|
||||
if (subBlock.id === 'contactId') {
|
||||
const itemType = 'contact'
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div className='w-full'>
|
||||
<WealthboxFileSelector
|
||||
value={
|
||||
(isPreview && previewValue !== undefined
|
||||
? (previewValue as string)
|
||||
: (storeValue as string)) || ''
|
||||
}
|
||||
onChange={(val) => {
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, val)
|
||||
}}
|
||||
provider='wealthbox'
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
serviceId={subBlock.serviceId}
|
||||
label={subBlock.placeholder || `Select ${itemType}`}
|
||||
disabled={finalDisabled || shouldForceDisable}
|
||||
showPreview={true}
|
||||
credentialId={credential}
|
||||
itemType={itemType}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
{!credential && (
|
||||
<Tooltip.Content side='top'>
|
||||
<p>Please select Wealthbox credentials first</p>
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
)
|
||||
}
|
||||
// noteId or taskId now use short-input
|
||||
return null
|
||||
}
|
||||
|
||||
// Default to Google Drive picker
|
||||
{
|
||||
const credential = ((isPreview && previewContextValues?.credential?.value) ||
|
||||
(connectedCredential as string) ||
|
||||
'') as string
|
||||
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div className='w-full'>
|
||||
<GoogleDrivePicker
|
||||
value={coerceToIdString(
|
||||
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
|
||||
)}
|
||||
onChange={(val) => {
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, val)
|
||||
}}
|
||||
provider={provider}
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
label={subBlock.placeholder || 'Select file'}
|
||||
disabled={finalDisabled || shouldForceDisable}
|
||||
serviceId={subBlock.serviceId}
|
||||
mimeTypeFilter={subBlock.mimeType}
|
||||
showPreview={true}
|
||||
clientId={clientId}
|
||||
apiKey={apiKey}
|
||||
credentialId={credential}
|
||||
workflowId={workflowIdFromUrl}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
{!credential && (
|
||||
<Tooltip.Content side='top'>
|
||||
<p>Please select Google Drive credentials first</p>
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<SelectorCombobox
|
||||
blockId={blockId}
|
||||
subBlock={subBlock}
|
||||
selectorKey={selectorResolution.key}
|
||||
selectorContext={selectorResolution.context}
|
||||
disabled={disabledReason}
|
||||
isPreview={isPreview}
|
||||
previewValue={previewValue ?? null}
|
||||
placeholder={subBlock.placeholder || 'Select resource'}
|
||||
allowSearch={selectorResolution.allowSearch}
|
||||
onOptionChange={(value) => {
|
||||
if (!isPreview) {
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, value)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,20 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { useRef, useState } from 'react'
|
||||
import { ChevronDown, X } from 'lucide-react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Button, Combobox } from '@/components/emcn/components'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
|
||||
@@ -59,31 +48,24 @@ export function FileUpload({
|
||||
previewValue,
|
||||
disabled = false,
|
||||
}: FileUploadProps) {
|
||||
// State management - handle both single file and array of files
|
||||
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId)
|
||||
const [uploadingFiles, setUploadingFiles] = useState<UploadingFile[]>([])
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
const [workspaceFiles, setWorkspaceFiles] = useState<WorkspaceFileRecord[]>([])
|
||||
const [loadingWorkspaceFiles, setLoadingWorkspaceFiles] = useState(false)
|
||||
const [uploadError, setUploadError] = useState<string | null>(null)
|
||||
const [addMoreOpen, setAddMoreOpen] = useState(false)
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
|
||||
// For file deletion status
|
||||
const [deletingFiles, setDeletingFiles] = useState<Record<string, boolean>>({})
|
||||
|
||||
// Refs
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Stores
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
const params = useParams()
|
||||
const workspaceId = params?.workspaceId as string
|
||||
|
||||
// Use preview value when in preview mode, otherwise use store value
|
||||
const value = isPreview ? previewValue : storeValue
|
||||
|
||||
// Load workspace files function
|
||||
const loadWorkspaceFiles = async () => {
|
||||
if (!workspaceId || isPreview) return
|
||||
|
||||
@@ -102,10 +84,8 @@ export function FileUpload({
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out already selected files
|
||||
const availableWorkspaceFiles = workspaceFiles.filter((workspaceFile) => {
|
||||
const existingFiles = Array.isArray(value) ? value : value ? [value] : []
|
||||
// Check if this workspace file is already added (match by name or key)
|
||||
return !existingFiles.some(
|
||||
(existing) =>
|
||||
existing.name === workspaceFile.name ||
|
||||
@@ -114,9 +94,12 @@ export function FileUpload({
|
||||
)
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
void loadWorkspaceFiles()
|
||||
}, [workspaceId])
|
||||
|
||||
/**
|
||||
* Opens file dialog
|
||||
* Prevents event propagation to avoid ReactFlow capturing the event
|
||||
*/
|
||||
const handleOpenFileDialog = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -159,18 +142,15 @@ export function FileUpload({
|
||||
const files = e.target.files
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
// Get existing files and their total size
|
||||
const existingFiles = Array.isArray(value) ? value : value ? [value] : []
|
||||
const existingTotalSize = existingFiles.reduce((sum, file) => sum + file.size, 0)
|
||||
|
||||
// Validate file sizes
|
||||
const maxSizeInBytes = maxSize * 1024 * 1024
|
||||
const validFiles: File[] = []
|
||||
let totalNewSize = 0
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i]
|
||||
// Check if adding this file would exceed the total limit
|
||||
if (existingTotalSize + totalNewSize + file.size > maxSizeInBytes) {
|
||||
logger.error(
|
||||
`Adding ${file.name} would exceed the maximum size limit of ${maxSize}MB`,
|
||||
@@ -184,7 +164,6 @@ export function FileUpload({
|
||||
|
||||
if (validFiles.length === 0) return
|
||||
|
||||
// Create placeholder uploading files - ensure unique IDs
|
||||
const uploading = validFiles.map((file) => ({
|
||||
id: `upload-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
name: file.name,
|
||||
@@ -194,13 +173,11 @@ export function FileUpload({
|
||||
setUploadingFiles(uploading)
|
||||
setUploadProgress(0)
|
||||
|
||||
// Track progress simulation interval
|
||||
let progressInterval: NodeJS.Timeout | null = null
|
||||
|
||||
try {
|
||||
setUploadError(null) // Clear previous errors
|
||||
setUploadError(null)
|
||||
|
||||
// Simulate upload progress
|
||||
progressInterval = setInterval(() => {
|
||||
setUploadProgress((prev) => {
|
||||
const newProgress = prev + Math.random() * 10
|
||||
@@ -211,20 +188,16 @@ export function FileUpload({
|
||||
const uploadedFiles: UploadedFile[] = []
|
||||
const uploadErrors: string[] = []
|
||||
|
||||
// Upload each file via server (workspace files need DB records)
|
||||
for (const file of validFiles) {
|
||||
try {
|
||||
// Create FormData for upload
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('context', 'workspace')
|
||||
|
||||
// Add workspace ID for workspace-scoped storage
|
||||
if (workspaceId) {
|
||||
formData.append('workspaceId', workspaceId)
|
||||
}
|
||||
|
||||
// Upload the file via server
|
||||
const response = await fetch('/api/files/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
@@ -232,37 +205,30 @@ export function FileUpload({
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Handle error response
|
||||
if (!response.ok) {
|
||||
const errorMessage = data.error || `Failed to upload file: ${response.status}`
|
||||
uploadErrors.push(`${file.name}: ${errorMessage}`)
|
||||
|
||||
// Set error message with conditional auto-dismiss
|
||||
setUploadError(errorMessage)
|
||||
|
||||
// Only auto-dismiss duplicate errors, keep other errors (like storage limits) visible
|
||||
if (data.isDuplicate || response.status === 409) {
|
||||
setTimeout(() => setUploadError(null), 5000)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if response has error even with 200 status
|
||||
if (data.success === false) {
|
||||
const errorMessage = data.error || 'Upload failed'
|
||||
uploadErrors.push(`${file.name}: ${errorMessage}`)
|
||||
|
||||
// Set error message with conditional auto-dismiss
|
||||
setUploadError(errorMessage)
|
||||
|
||||
// Only auto-dismiss duplicate errors, keep other errors (like storage limits) visible
|
||||
if (data.isDuplicate) {
|
||||
setTimeout(() => setUploadError(null), 5000)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Process successful upload - handle both workspace and regular uploads
|
||||
uploadedFiles.push({
|
||||
name: file.name,
|
||||
path: data.file?.url || data.url, // Workspace: data.file.url, Non-workspace: data.url
|
||||
@@ -277,7 +243,6 @@ export function FileUpload({
|
||||
}
|
||||
}
|
||||
|
||||
// Clear progress interval
|
||||
if (progressInterval) {
|
||||
clearInterval(progressInterval)
|
||||
progressInterval = null
|
||||
@@ -285,11 +250,9 @@ export function FileUpload({
|
||||
|
||||
setUploadProgress(100)
|
||||
|
||||
// Send consolidated notification about uploaded files
|
||||
if (uploadedFiles.length > 0) {
|
||||
setUploadError(null) // Clear error on successful upload
|
||||
setUploadError(null)
|
||||
|
||||
// Refresh workspace files list to keep dropdown up to date
|
||||
if (workspaceId) {
|
||||
void loadWorkspaceFiles()
|
||||
}
|
||||
@@ -304,7 +267,6 @@ export function FileUpload({
|
||||
}
|
||||
}
|
||||
|
||||
// Send consolidated error notification if any
|
||||
if (uploadErrors.length > 0) {
|
||||
if (uploadErrors.length === 1) {
|
||||
logger.error(uploadErrors[0], activeWorkflowId)
|
||||
@@ -316,30 +278,23 @@ export function FileUpload({
|
||||
}
|
||||
}
|
||||
|
||||
// Update the file value in state based on multiple setting
|
||||
if (multiple) {
|
||||
// For multiple files: Append to existing files if any
|
||||
const existingFiles = Array.isArray(value) ? value : value ? [value] : []
|
||||
// Create a map to identify duplicates by url
|
||||
const uniqueFiles = new Map()
|
||||
|
||||
// Add existing files to the map
|
||||
existingFiles.forEach((file) => {
|
||||
uniqueFiles.set(file.url || file.path, file) // Use url, fallback to path for backward compatibility
|
||||
uniqueFiles.set(file.url || file.path, file)
|
||||
})
|
||||
|
||||
// Add new files to the map (will overwrite if same path)
|
||||
uploadedFiles.forEach((file) => {
|
||||
uniqueFiles.set(file.path, file)
|
||||
})
|
||||
|
||||
// Convert map values back to array
|
||||
const newFiles = Array.from(uniqueFiles.values())
|
||||
|
||||
setStoreValue(newFiles)
|
||||
useWorkflowStore.getState().triggerUpdate()
|
||||
} else {
|
||||
// For single file: Replace with last uploaded file
|
||||
setStoreValue(uploadedFiles[0] || null)
|
||||
useWorkflowStore.getState().triggerUpdate()
|
||||
}
|
||||
@@ -349,7 +304,6 @@ export function FileUpload({
|
||||
activeWorkflowId
|
||||
)
|
||||
} finally {
|
||||
// Clean up and reset upload state
|
||||
if (progressInterval) {
|
||||
clearInterval(progressInterval)
|
||||
}
|
||||
@@ -368,8 +322,6 @@ export function FileUpload({
|
||||
const selectedFile = workspaceFiles.find((f) => f.id === fileId)
|
||||
if (!selectedFile) return
|
||||
|
||||
// Convert workspace file record to uploaded file format
|
||||
// Path will be converted to presigned URL during execution if needed
|
||||
const uploadedFile: UploadedFile = {
|
||||
name: selectedFile.name,
|
||||
path: selectedFile.path,
|
||||
@@ -378,7 +330,6 @@ export function FileUpload({
|
||||
}
|
||||
|
||||
if (multiple) {
|
||||
// For multiple files: Append to existing
|
||||
const existingFiles = Array.isArray(value) ? value : value ? [value] : []
|
||||
const uniqueFiles = new Map()
|
||||
|
||||
@@ -391,7 +342,6 @@ export function FileUpload({
|
||||
|
||||
setStoreValue(newFiles)
|
||||
} else {
|
||||
// For single file: Replace
|
||||
setStoreValue(uploadedFile)
|
||||
}
|
||||
|
||||
@@ -408,19 +358,15 @@ export function FileUpload({
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
// Mark this file as being deleted
|
||||
setDeletingFiles((prev) => ({ ...prev, [file.path || '']: true }))
|
||||
|
||||
try {
|
||||
// Check if this is a workspace file (decoded path contains workspaceId pattern)
|
||||
const decodedPath = file.path ? decodeURIComponent(file.path) : ''
|
||||
const isWorkspaceFile =
|
||||
workspaceId &&
|
||||
(decodedPath.includes(`/${workspaceId}/`) || decodedPath.includes(`${workspaceId}/`))
|
||||
|
||||
if (!isWorkspaceFile) {
|
||||
// Only delete from storage if it's NOT a workspace file
|
||||
// Workspace files are permanent and managed through Settings
|
||||
const response = await fetch('/api/files/delete', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -436,14 +382,11 @@ export function FileUpload({
|
||||
}
|
||||
}
|
||||
|
||||
// Update the UI state (remove from selection)
|
||||
if (multiple) {
|
||||
// For multiple files: Remove the specific file
|
||||
const filesArray = Array.isArray(value) ? value : value ? [value] : []
|
||||
const updatedFiles = filesArray.filter((f) => f.path !== file.path)
|
||||
setStoreValue(updatedFiles.length > 0 ? updatedFiles : null)
|
||||
} else {
|
||||
// For single file: Clear the value
|
||||
setStoreValue(null)
|
||||
}
|
||||
|
||||
@@ -454,7 +397,6 @@ export function FileUpload({
|
||||
activeWorkflowId
|
||||
)
|
||||
} finally {
|
||||
// Remove file from the deleting state
|
||||
setDeletingFiles((prev) => {
|
||||
const updated = { ...prev }
|
||||
delete updated[file.path || '']
|
||||
@@ -463,80 +405,6 @@ export function FileUpload({
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles deletion of all files (for multiple mode)
|
||||
*/
|
||||
const handleRemoveAllFiles = async (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
if (!value) return
|
||||
|
||||
const filesToDelete = Array.isArray(value) ? value : [value]
|
||||
|
||||
// Mark all files as deleting
|
||||
const deletingStatus: Record<string, boolean> = {}
|
||||
filesToDelete.forEach((file) => {
|
||||
deletingStatus[file.path || ''] = true
|
||||
})
|
||||
setDeletingFiles(deletingStatus)
|
||||
|
||||
// Clear input state immediately for better UX
|
||||
setStoreValue(null)
|
||||
useWorkflowStore.getState().triggerUpdate()
|
||||
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
|
||||
// Track successful and failed deletions
|
||||
const deletionResults = {
|
||||
success: 0,
|
||||
failures: [] as string[],
|
||||
}
|
||||
|
||||
// Delete each file
|
||||
for (const file of filesToDelete) {
|
||||
try {
|
||||
const response = await fetch('/api/files/delete', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ filePath: file.path }),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
deletionResults.success++
|
||||
} else {
|
||||
const errorData = await response.json().catch(() => ({ error: response.statusText }))
|
||||
const errorMessage = errorData.error || `Failed to delete file: ${response.status}`
|
||||
deletionResults.failures.push(`${file.name}: ${errorMessage}`)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to delete file ${file.name}:`, error)
|
||||
deletionResults.failures.push(
|
||||
`${file.name}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Show error notification if any deletions failed
|
||||
if (deletionResults.failures.length > 0) {
|
||||
if (deletionResults.failures.length === 1) {
|
||||
logger.error(`Failed to delete file: ${deletionResults.failures[0]}`, activeWorkflowId)
|
||||
} else {
|
||||
logger.error(
|
||||
`Failed to delete ${deletionResults.failures.length} files: ${deletionResults.failures.join('; ')}`,
|
||||
activeWorkflowId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
setDeletingFiles({})
|
||||
}
|
||||
|
||||
// Helper to render a single file item
|
||||
const renderFileItem = (file: UploadedFile) => {
|
||||
const fileKey = file.path || ''
|
||||
const isDeleting = deletingFiles[fileKey]
|
||||
@@ -544,19 +412,16 @@ export function FileUpload({
|
||||
return (
|
||||
<div
|
||||
key={fileKey}
|
||||
className='flex items-center justify-between rounded border border-border bg-background px-3 py-2'
|
||||
className='flex items-center justify-between rounded-[4px] border border-[var(--surface-11)] bg-[var(--surface-6)] px-[8px] py-[6px] hover:border-[var(--surface-14)] hover:bg-[var(--surface-9)] dark:bg-[var(--surface-9)] dark:hover:bg-[var(--surface-11)]'
|
||||
>
|
||||
<div className='flex-1 truncate pr-2'>
|
||||
<div className='truncate font-normal text-sm' title={file.name}>
|
||||
{truncateMiddle(file.name)}
|
||||
</div>
|
||||
<div className='text-muted-foreground text-xs'>{formatFileSize(file.size)}</div>
|
||||
<div className='flex-1 truncate pr-2 text-sm' title={file.name}>
|
||||
<span className='text-[var(--text-primary)]'>{truncateMiddle(file.name)}</span>
|
||||
<span className='ml-2 text-[var(--text-muted)]'>({formatFileSize(file.size)})</span>
|
||||
</div>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-8 w-8 shrink-0'
|
||||
className='h-6 w-6 shrink-0 p-0'
|
||||
onClick={(e) => handleRemoveFile(file, e)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
@@ -570,16 +435,15 @@ export function FileUpload({
|
||||
)
|
||||
}
|
||||
|
||||
// Render a placeholder item for files being uploaded
|
||||
const renderUploadingItem = (file: UploadingFile) => {
|
||||
return (
|
||||
<div
|
||||
key={file.id}
|
||||
className='flex items-center justify-between rounded border border-border bg-background px-3 py-2'
|
||||
className='flex items-center justify-between rounded-[4px] border border-[var(--surface-11)] bg-[var(--surface-6)] px-[8px] py-[6px] dark:bg-[var(--surface-9)]'
|
||||
>
|
||||
<div className='flex-1 truncate pr-2'>
|
||||
<div className='truncate font-normal text-sm'>{file.name}</div>
|
||||
<div className='text-muted-foreground text-xs'>{formatFileSize(file.size)}</div>
|
||||
<div className='flex-1 truncate pr-2 text-sm'>
|
||||
<span className='text-[var(--text-primary)]'>{file.name}</span>
|
||||
<span className='ml-2 text-[var(--text-muted)]'>({formatFileSize(file.size)})</span>
|
||||
</div>
|
||||
<div className='flex h-8 w-8 shrink-0 items-center justify-center'>
|
||||
<div className='h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
|
||||
@@ -588,11 +452,43 @@ export function FileUpload({
|
||||
)
|
||||
}
|
||||
|
||||
// Get files array regardless of multiple setting
|
||||
const filesArray = Array.isArray(value) ? value : value ? [value] : []
|
||||
const hasFiles = filesArray.length > 0
|
||||
const isUploading = uploadingFiles.length > 0
|
||||
|
||||
const comboboxOptions = useMemo(
|
||||
() => [
|
||||
{ label: 'Upload New File', value: '__upload_new__' },
|
||||
...availableWorkspaceFiles.map((file) => ({
|
||||
label: file.name,
|
||||
value: file.id,
|
||||
})),
|
||||
],
|
||||
[availableWorkspaceFiles]
|
||||
)
|
||||
|
||||
const handleComboboxChange = (value: string) => {
|
||||
setInputValue(value)
|
||||
|
||||
const isValidOption =
|
||||
value === '__upload_new__' || availableWorkspaceFiles.some((file) => file.id === value)
|
||||
|
||||
if (!isValidOption) {
|
||||
return
|
||||
}
|
||||
|
||||
setInputValue('')
|
||||
|
||||
if (value === '__upload_new__') {
|
||||
handleOpenFileDialog({
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
} as React.MouseEvent)
|
||||
} else {
|
||||
handleSelectWorkspaceFile(value)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='w-full' onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
@@ -614,7 +510,6 @@ export function FileUpload({
|
||||
<div className='mb-2 space-y-2'>
|
||||
{/* Only show files that aren't currently uploading */}
|
||||
{filesArray.map((file) => {
|
||||
// Don't show files that have duplicates in the uploading list
|
||||
const isCurrentlyUploading = uploadingFiles.some(
|
||||
(uploadingFile) => uploadingFile.name === file.name
|
||||
)
|
||||
@@ -641,73 +536,19 @@ export function FileUpload({
|
||||
{/* Add More dropdown for multiple files */}
|
||||
{hasFiles && multiple && !isUploading && (
|
||||
<div>
|
||||
<Popover
|
||||
open={addMoreOpen}
|
||||
<Combobox
|
||||
options={comboboxOptions}
|
||||
value={inputValue}
|
||||
onChange={handleComboboxChange}
|
||||
onOpenChange={(open) => {
|
||||
setAddMoreOpen(open)
|
||||
if (open) void loadWorkspaceFiles()
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
role='combobox'
|
||||
aria-expanded={addMoreOpen}
|
||||
className='relative w-full justify-between'
|
||||
disabled={disabled || loadingWorkspaceFiles}
|
||||
>
|
||||
<span className='truncate font-normal'>+ Add More</span>
|
||||
<ChevronDown className='absolute right-3 h-4 w-4 shrink-0 opacity-50' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-[320px] p-0' align='start'>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder='Search files...'
|
||||
className='text-foreground placeholder:text-muted-foreground'
|
||||
/>
|
||||
<CommandList onWheel={(e) => e.stopPropagation()}>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value='upload_new'
|
||||
onSelect={() => {
|
||||
setAddMoreOpen(false)
|
||||
handleOpenFileDialog({
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
} as React.MouseEvent)
|
||||
}}
|
||||
>
|
||||
Upload New File
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
<CommandEmpty>
|
||||
{availableWorkspaceFiles.length === 0
|
||||
? 'No files available.'
|
||||
: 'No files found.'}
|
||||
</CommandEmpty>
|
||||
{availableWorkspaceFiles.length > 0 && (
|
||||
<CommandGroup heading='Workspace Files'>
|
||||
{availableWorkspaceFiles.map((file) => (
|
||||
<CommandItem
|
||||
key={file.id}
|
||||
value={file.name}
|
||||
onSelect={() => {
|
||||
handleSelectWorkspaceFile(file.id)
|
||||
setAddMoreOpen(false)
|
||||
}}
|
||||
>
|
||||
<span className='truncate' title={file.name}>
|
||||
{truncateMiddle(file.name)}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
placeholder={loadingWorkspaceFiles ? 'Loading files...' : '+ Add More'}
|
||||
disabled={disabled || loadingWorkspaceFiles}
|
||||
editable={true}
|
||||
filterOptions={true}
|
||||
isLoading={loadingWorkspaceFiles}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -715,75 +556,19 @@ export function FileUpload({
|
||||
{/* Show dropdown selector if no files and not uploading */}
|
||||
{!hasFiles && !isUploading && (
|
||||
<div className='flex items-center'>
|
||||
<Popover
|
||||
open={pickerOpen}
|
||||
<Combobox
|
||||
options={comboboxOptions}
|
||||
value={inputValue}
|
||||
onChange={handleComboboxChange}
|
||||
onOpenChange={(open) => {
|
||||
setPickerOpen(open)
|
||||
if (open) void loadWorkspaceFiles()
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
role='combobox'
|
||||
aria-expanded={pickerOpen}
|
||||
className='relative w-full justify-between'
|
||||
disabled={disabled || loadingWorkspaceFiles}
|
||||
>
|
||||
<span className='truncate font-normal'>
|
||||
{loadingWorkspaceFiles ? 'Loading files...' : 'Select or upload file'}
|
||||
</span>
|
||||
<ChevronDown className='absolute right-3 h-4 w-4 shrink-0 opacity-50' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-[320px] p-0' align='start'>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder='Search files...'
|
||||
className='text-foreground placeholder:text-muted-foreground'
|
||||
/>
|
||||
<CommandList onWheel={(e) => e.stopPropagation()}>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value='upload_new'
|
||||
onSelect={() => {
|
||||
setPickerOpen(false)
|
||||
handleOpenFileDialog({
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
} as React.MouseEvent)
|
||||
}}
|
||||
>
|
||||
Upload New File
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
<CommandEmpty>
|
||||
{availableWorkspaceFiles.length === 0
|
||||
? 'No files available.'
|
||||
: 'No files found.'}
|
||||
</CommandEmpty>
|
||||
{availableWorkspaceFiles.length > 0 && (
|
||||
<CommandGroup heading='Workspace Files'>
|
||||
{availableWorkspaceFiles.map((file) => (
|
||||
<CommandItem
|
||||
key={file.id}
|
||||
value={file.name}
|
||||
onSelect={() => {
|
||||
handleSelectWorkspaceFile(file.id)
|
||||
setPickerOpen(false)
|
||||
}}
|
||||
>
|
||||
<span className='truncate' title={file.name}>
|
||||
{truncateMiddle(file.name)}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
placeholder={loadingWorkspaceFiles ? 'Loading files...' : 'Select or upload file'}
|
||||
disabled={disabled || loadingWorkspaceFiles}
|
||||
editable={true}
|
||||
filterOptions={true}
|
||||
isLoading={loadingWorkspaceFiles}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
type FolderInfo,
|
||||
FolderSelector,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/folder-selector/folder-selector'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
|
||||
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-depends-on-gate'
|
||||
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-foreign-credential'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { resolveSelectorForSubBlock } from '@/hooks/selectors/resolution'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
@@ -27,19 +25,19 @@ export function FolderSelectorInput({
|
||||
isPreview = false,
|
||||
previewValue,
|
||||
}: FolderSelectorInputProps) {
|
||||
const [storeValue, _setStoreValue] = useSubBlockValue(blockId, subBlock.id)
|
||||
const [storeValue] = useSubBlockValue(blockId, subBlock.id)
|
||||
const [connectedCredential] = useSubBlockValue(blockId, 'credential')
|
||||
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
const [selectedFolderId, setSelectedFolderId] = useState<string>('')
|
||||
const [_folderInfo, setFolderInfo] = useState<FolderInfo | null>(null)
|
||||
const provider = (subBlock.provider || subBlock.serviceId || 'google-email').toLowerCase()
|
||||
const providerKey = (subBlock.provider ?? subBlock.serviceId ?? '').toLowerCase()
|
||||
const credentialProvider = subBlock.serviceId ?? subBlock.provider
|
||||
const isCopyDestinationSelector =
|
||||
subBlock.canonicalParamId === 'copyDestinationId' ||
|
||||
subBlock.id === 'copyDestinationFolder' ||
|
||||
subBlock.id === 'manualCopyDestinationFolder'
|
||||
const { isForeignCredential } = useForeignCredential(
|
||||
subBlock.provider || subBlock.serviceId || 'outlook',
|
||||
credentialProvider,
|
||||
(connectedCredential as string) || ''
|
||||
)
|
||||
|
||||
@@ -48,26 +46,22 @@ export function FolderSelectorInput({
|
||||
|
||||
// Get the current value from the store or prop value if in preview mode
|
||||
useEffect(() => {
|
||||
// When gated/disabled, do not set defaults or write to store
|
||||
if (finalDisabled) return
|
||||
if (isPreview && previewValue !== undefined) {
|
||||
setSelectedFolderId(previewValue)
|
||||
return
|
||||
}
|
||||
const current = storeValue as string | undefined
|
||||
if (current && typeof current === 'string') {
|
||||
if (current) {
|
||||
setSelectedFolderId(current)
|
||||
return
|
||||
}
|
||||
const shouldDefaultInbox = provider !== 'outlook' && !isCopyDestinationSelector
|
||||
const shouldDefaultInbox = providerKey === 'gmail' && !isCopyDestinationSelector
|
||||
if (shouldDefaultInbox) {
|
||||
const defaultValue = 'INBOX'
|
||||
setSelectedFolderId(defaultValue)
|
||||
setSelectedFolderId('INBOX')
|
||||
if (!isPreview) {
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, defaultValue)
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, 'INBOX')
|
||||
}
|
||||
} else {
|
||||
setSelectedFolderId('')
|
||||
}
|
||||
}, [
|
||||
blockId,
|
||||
@@ -77,33 +71,46 @@ export function FolderSelectorInput({
|
||||
isPreview,
|
||||
previewValue,
|
||||
finalDisabled,
|
||||
providerKey,
|
||||
isCopyDestinationSelector,
|
||||
])
|
||||
|
||||
// Handle folder selection
|
||||
const handleFolderChange = useCallback(
|
||||
(folderId: string, info?: FolderInfo) => {
|
||||
setSelectedFolderId(folderId)
|
||||
setFolderInfo(info || null)
|
||||
const credentialId = (connectedCredential as string) || ''
|
||||
const missingCredential = credentialId.length === 0
|
||||
const selectorResolution = useMemo(
|
||||
() =>
|
||||
resolveSelectorForSubBlock(subBlock, {
|
||||
credentialId: credentialId || undefined,
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
}),
|
||||
[subBlock, credentialId, activeWorkflowId]
|
||||
)
|
||||
|
||||
const handleChange = useCallback(
|
||||
(value: string) => {
|
||||
setSelectedFolderId(value)
|
||||
if (!isPreview) {
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, folderId)
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, value)
|
||||
}
|
||||
},
|
||||
[blockId, subBlock.id, collaborativeSetSubblockValue, isPreview]
|
||||
)
|
||||
|
||||
return (
|
||||
<FolderSelector
|
||||
value={selectedFolderId}
|
||||
onChange={handleFolderChange}
|
||||
provider={provider}
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
label={subBlock.placeholder || 'Select folder'}
|
||||
disabled={finalDisabled}
|
||||
serviceId={subBlock.serviceId}
|
||||
onFolderInfoChange={setFolderInfo}
|
||||
credentialId={(connectedCredential as string) || ''}
|
||||
workflowId={activeWorkflowId || ''}
|
||||
isForeignCredential={isForeignCredential}
|
||||
<SelectorCombobox
|
||||
blockId={blockId}
|
||||
subBlock={subBlock}
|
||||
selectorKey={selectorResolution?.key ?? 'gmail.labels'}
|
||||
selectorContext={
|
||||
selectorResolution?.context ?? { credentialId, workflowId: activeWorkflowId || '' }
|
||||
}
|
||||
disabled={
|
||||
finalDisabled || isForeignCredential || missingCredential || !selectorResolution?.key
|
||||
}
|
||||
isPreview={isPreview}
|
||||
previewValue={previewValue ?? null}
|
||||
placeholder={subBlock.placeholder || 'Select folder'}
|
||||
onOptionChange={handleChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,533 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Check, ChevronDown, RefreshCw } from 'lucide-react'
|
||||
import { GmailIcon, OutlookIcon } from '@/components/icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { type Credential, getProviderIdFromServiceId, getServiceIdFromScopes } from '@/lib/oauth'
|
||||
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
|
||||
import { useDisplayNamesStore } from '@/stores/display-names/store'
|
||||
|
||||
const logger = createLogger('FolderSelector')
|
||||
|
||||
export interface FolderInfo {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
messagesTotal?: number
|
||||
messagesUnread?: number
|
||||
}
|
||||
|
||||
interface FolderSelectorProps {
|
||||
value: string
|
||||
onChange: (value: string, folderInfo?: FolderInfo) => void
|
||||
provider: string
|
||||
requiredScopes?: string[]
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
serviceId?: string
|
||||
onFolderInfoChange?: (folderInfo: FolderInfo | null) => void
|
||||
isPreview?: boolean
|
||||
previewValue?: any | null
|
||||
credentialId?: string
|
||||
workflowId?: string
|
||||
isForeignCredential?: boolean
|
||||
}
|
||||
|
||||
export function FolderSelector({
|
||||
value,
|
||||
onChange,
|
||||
provider,
|
||||
requiredScopes = [],
|
||||
label = 'Select folder',
|
||||
disabled = false,
|
||||
serviceId,
|
||||
onFolderInfoChange,
|
||||
isPreview = false,
|
||||
previewValue,
|
||||
credentialId,
|
||||
workflowId,
|
||||
isForeignCredential = false,
|
||||
}: FolderSelectorProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [credentials, setCredentials] = useState<Credential[]>([])
|
||||
const [folders, setFolders] = useState<FolderInfo[]>([])
|
||||
const [selectedCredentialId, setSelectedCredentialId] = useState<Credential['id'] | ''>(
|
||||
credentialId || ''
|
||||
)
|
||||
const [selectedFolderId, setSelectedFolderId] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isLoadingSelectedFolder, setIsLoadingSelectedFolder] = useState(false)
|
||||
const [showOAuthModal, setShowOAuthModal] = useState(false)
|
||||
const initialFetchRef = useRef(false)
|
||||
|
||||
// Get cached display name
|
||||
const cachedFolderName = useDisplayNamesStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
const effectiveCredentialId = credentialId || selectedCredentialId
|
||||
const effectiveValue = isPreview && previewValue !== undefined ? previewValue : value
|
||||
if (!effectiveCredentialId || !effectiveValue) return null
|
||||
return state.cache.folders[effectiveCredentialId]?.[effectiveValue] || null
|
||||
},
|
||||
[credentialId, selectedCredentialId, value, isPreview, previewValue]
|
||||
)
|
||||
)
|
||||
|
||||
// Initialize selectedFolderId with the effective value
|
||||
useEffect(() => {
|
||||
if (isPreview && previewValue !== undefined) {
|
||||
setSelectedFolderId(previewValue || '')
|
||||
} else {
|
||||
setSelectedFolderId(value)
|
||||
}
|
||||
}, [value, isPreview, previewValue])
|
||||
|
||||
// Keep internal credential in sync with prop
|
||||
useEffect(() => {
|
||||
if (credentialId && credentialId !== selectedCredentialId) {
|
||||
setSelectedCredentialId(credentialId)
|
||||
}
|
||||
}, [credentialId, selectedCredentialId])
|
||||
|
||||
// Determine the appropriate service ID based on provider and scopes
|
||||
const getServiceId = (): string => {
|
||||
if (serviceId) return serviceId
|
||||
return getServiceIdFromScopes(provider, requiredScopes)
|
||||
}
|
||||
|
||||
// Determine the appropriate provider ID based on service and scopes
|
||||
const getProviderId = (): string => {
|
||||
const effectiveServiceId = getServiceId()
|
||||
return getProviderIdFromServiceId(effectiveServiceId)
|
||||
}
|
||||
|
||||
// Fetch available credentials for this provider
|
||||
const fetchCredentials = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const providerId = getProviderId()
|
||||
const response = await fetch(`/api/auth/oauth/credentials?provider=${providerId}`)
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setCredentials(data.credentials)
|
||||
|
||||
// Auto-select logic for credentials
|
||||
if (data.credentials.length > 0) {
|
||||
// If we already have a selected credential ID, check if it's valid
|
||||
if (
|
||||
selectedCredentialId &&
|
||||
data.credentials.some((cred: Credential) => cred.id === selectedCredentialId)
|
||||
) {
|
||||
// Keep the current selection
|
||||
} else {
|
||||
// Otherwise, select the default or first credential
|
||||
const defaultCred = data.credentials.find((cred: Credential) => cred.isDefault)
|
||||
if (defaultCred) {
|
||||
setSelectedCredentialId(defaultCred.id)
|
||||
} else if (data.credentials.length === 1) {
|
||||
setSelectedCredentialId(data.credentials[0].id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching credentials:', { error })
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [provider, getProviderId, selectedCredentialId])
|
||||
|
||||
// Fetch a single folder by ID when we have a selectedFolderId but no metadata
|
||||
const fetchFolderById = useCallback(
|
||||
async (folderId: string) => {
|
||||
if (!selectedCredentialId || !folderId) return null
|
||||
|
||||
setIsLoadingSelectedFolder(true)
|
||||
try {
|
||||
if (provider === 'outlook') {
|
||||
// Resolve Outlook folder name with owner-scoped token
|
||||
const tokenRes = await fetch('/api/auth/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ credentialId: selectedCredentialId, workflowId }),
|
||||
})
|
||||
if (!tokenRes.ok) return null
|
||||
const { accessToken } = await tokenRes.json()
|
||||
if (!accessToken) return null
|
||||
const resp = await fetch(
|
||||
`https://graph.microsoft.com/v1.0/me/mailFolders/${encodeURIComponent(folderId)}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
}
|
||||
)
|
||||
if (!resp.ok) return null
|
||||
const folder = await resp.json()
|
||||
const folderInfo: FolderInfo = {
|
||||
id: folder.id,
|
||||
name: folder.displayName,
|
||||
type: 'folder',
|
||||
messagesTotal: folder.totalItemCount,
|
||||
messagesUnread: folder.unreadItemCount,
|
||||
}
|
||||
onFolderInfoChange?.(folderInfo)
|
||||
return folderInfo
|
||||
}
|
||||
// Gmail label resolution
|
||||
const queryParams = new URLSearchParams({
|
||||
credentialId: selectedCredentialId,
|
||||
labelId: folderId,
|
||||
})
|
||||
const response = await fetch(`/api/tools/gmail/label?${queryParams.toString()}`)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
if (data.label) {
|
||||
onFolderInfoChange?.(data.label)
|
||||
return data.label
|
||||
}
|
||||
} else {
|
||||
logger.error('Error fetching folder by ID:', {
|
||||
error: await response.text(),
|
||||
})
|
||||
}
|
||||
return null
|
||||
} catch (error) {
|
||||
logger.error('Error fetching folder by ID:', { error })
|
||||
return null
|
||||
} finally {
|
||||
setIsLoadingSelectedFolder(false)
|
||||
}
|
||||
},
|
||||
[selectedCredentialId, onFolderInfoChange, provider, workflowId]
|
||||
)
|
||||
|
||||
// Fetch folders from Gmail or Outlook
|
||||
const fetchFolders = useCallback(
|
||||
async (searchQuery?: string) => {
|
||||
if (!selectedCredentialId) return
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
// Construct query parameters
|
||||
const queryParams = new URLSearchParams({
|
||||
credentialId: selectedCredentialId,
|
||||
})
|
||||
|
||||
if (searchQuery) {
|
||||
queryParams.append('query', searchQuery)
|
||||
}
|
||||
|
||||
// Determine the API endpoint based on provider
|
||||
let apiEndpoint: string
|
||||
if (provider === 'outlook') {
|
||||
// Skip list fetch for collaborators; only show selected
|
||||
if (isForeignCredential) {
|
||||
setFolders([])
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
apiEndpoint = `/api/tools/outlook/folders?${queryParams.toString()}`
|
||||
} else {
|
||||
// Default to Gmail
|
||||
apiEndpoint = `/api/tools/gmail/labels?${queryParams.toString()}`
|
||||
}
|
||||
|
||||
const response = await fetch(apiEndpoint)
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
const folderList = provider === 'outlook' ? data.folders : data.labels
|
||||
setFolders(folderList || [])
|
||||
|
||||
// Cache folder names in display names store
|
||||
if (selectedCredentialId && folderList) {
|
||||
const folderMap = folderList.reduce(
|
||||
(acc: Record<string, string>, folder: FolderInfo) => {
|
||||
acc[folder.id] = folder.name
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
useDisplayNamesStore
|
||||
.getState()
|
||||
.setDisplayNames('folders', selectedCredentialId, folderMap)
|
||||
}
|
||||
|
||||
// Only notify parent if callback exists
|
||||
if (selectedFolderId && onFolderInfoChange) {
|
||||
const folderInfo = folderList.find(
|
||||
(folder: FolderInfo) => folder.id === selectedFolderId
|
||||
)
|
||||
if (folderInfo) {
|
||||
onFolderInfoChange(folderInfo)
|
||||
} else if (!searchQuery && provider !== 'outlook') {
|
||||
// Only try to fetch by ID for Gmail if this is not a search query
|
||||
// and we couldn't find the folder in the list
|
||||
fetchFolderById(selectedFolderId)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const text = await response.text()
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
logger.info('Folder list fetch unauthorized (expected for collaborator)')
|
||||
} else {
|
||||
logger.warn('Error fetching folders', { status: response.status, text })
|
||||
}
|
||||
setFolders([])
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching folders:', { error })
|
||||
setFolders([])
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
},
|
||||
[
|
||||
selectedCredentialId,
|
||||
selectedFolderId,
|
||||
onFolderInfoChange,
|
||||
fetchFolderById,
|
||||
provider,
|
||||
isForeignCredential,
|
||||
]
|
||||
)
|
||||
|
||||
// Fetch credentials on initial mount
|
||||
useEffect(() => {
|
||||
if (disabled) return
|
||||
if (!initialFetchRef.current) {
|
||||
fetchCredentials()
|
||||
initialFetchRef.current = true
|
||||
}
|
||||
}, [fetchCredentials, disabled])
|
||||
|
||||
// Fetch folders when credential is selected
|
||||
useEffect(() => {
|
||||
if (disabled) return
|
||||
if (selectedCredentialId) {
|
||||
fetchFolders()
|
||||
}
|
||||
}, [selectedCredentialId, fetchFolders, disabled])
|
||||
|
||||
// Keep internal selectedFolderId in sync with the value prop
|
||||
useEffect(() => {
|
||||
if (disabled) return
|
||||
const currentValue = isPreview ? previewValue : value
|
||||
if (currentValue !== selectedFolderId) {
|
||||
setSelectedFolderId(currentValue || '')
|
||||
}
|
||||
}, [value, isPreview, previewValue, disabled, selectedFolderId])
|
||||
|
||||
// Handle folder selection
|
||||
const handleSelectFolder = (folder: FolderInfo) => {
|
||||
setSelectedFolderId(folder.id)
|
||||
if (!isPreview) {
|
||||
onChange(folder.id, folder)
|
||||
}
|
||||
onFolderInfoChange?.(folder)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
// Handle adding a new credential
|
||||
const handleAddCredential = () => {
|
||||
// Show the OAuth modal
|
||||
setShowOAuthModal(true)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
if (value.length > 2) {
|
||||
fetchFolders(value)
|
||||
} else if (value.length === 0) {
|
||||
fetchFolders()
|
||||
}
|
||||
}
|
||||
|
||||
const getFolderIcon = (size: 'sm' | 'md' = 'sm') => {
|
||||
const iconSize = size === 'sm' ? 'h-4 w-4' : 'h-5 w-5'
|
||||
if (provider === 'gmail') {
|
||||
return <GmailIcon className={iconSize} />
|
||||
}
|
||||
if (provider === 'outlook') {
|
||||
return <OutlookIcon className={iconSize} />
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const getProviderName = () => {
|
||||
if (provider === 'outlook') return 'Outlook'
|
||||
return 'Gmail'
|
||||
}
|
||||
|
||||
const getFolderLabel = () => {
|
||||
if (provider === 'outlook') return 'folders'
|
||||
return 'labels'
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-2'>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
role='combobox'
|
||||
aria-expanded={open}
|
||||
className='w-full justify-between'
|
||||
disabled={disabled || isForeignCredential}
|
||||
>
|
||||
{cachedFolderName ? (
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
{getFolderIcon('sm')}
|
||||
<span className='truncate font-normal'>{cachedFolderName}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex items-center gap-2'>
|
||||
{getFolderIcon('sm')}
|
||||
<span className='text-muted-foreground'>{label}</span>
|
||||
</div>
|
||||
)}
|
||||
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
{!isForeignCredential && (
|
||||
<PopoverContent className='w-[300px] p-0' align='start'>
|
||||
{/* Current account indicator */}
|
||||
{selectedCredentialId && credentials.length > 0 && (
|
||||
<div className='flex items-center justify-between border-b px-3 py-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
|
||||
'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
{credentials.length > 1 && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 px-2 text-xs'
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Switch
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={`Search ${getFolderLabel()}...`}
|
||||
onValueChange={handleSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{isLoading ? (
|
||||
<div className='flex items-center justify-center p-4'>
|
||||
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||
<span className='ml-2'>Loading {getFolderLabel()}...</span>
|
||||
</div>
|
||||
) : credentials.length === 0 ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No accounts connected.</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Connect a {getProviderName()} account to continue.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No {getFolderLabel()} found.</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Try a different search or account.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CommandEmpty>
|
||||
|
||||
{/* Account selection - only show if we have multiple accounts */}
|
||||
{credentials.length > 1 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
Switch Account
|
||||
</div>
|
||||
{credentials.map((cred) => (
|
||||
<CommandItem
|
||||
key={cred.id}
|
||||
value={`account-${cred.id}`}
|
||||
onSelect={() => setSelectedCredentialId(cred.id)}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='font-normal'>{cred.name}</span>
|
||||
</div>
|
||||
{cred.id === selectedCredentialId && (
|
||||
<Check className='ml-auto h-4 w-4' />
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Folders list */}
|
||||
{folders.length > 0 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
{getFolderLabel().charAt(0).toUpperCase() + getFolderLabel().slice(1)}
|
||||
</div>
|
||||
{folders.map((folder) => (
|
||||
<CommandItem
|
||||
key={folder.id}
|
||||
value={`folder-${folder.id}-${folder.name}`}
|
||||
onSelect={() => handleSelectFolder(folder)}
|
||||
>
|
||||
<div className='flex w-full items-center gap-2 overflow-hidden'>
|
||||
{getFolderIcon('sm')}
|
||||
<span className='truncate font-normal'>{folder.name}</span>
|
||||
{folder.id === selectedFolderId && (
|
||||
<Check className='ml-auto h-4 w-4' />
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Connect account option - only show if no credentials */}
|
||||
{credentials.length === 0 && (
|
||||
<CommandGroup>
|
||||
<CommandItem onSelect={handleAddCredential}>
|
||||
<div className='flex items-center gap-2 text-foreground'>
|
||||
<span>Connect {getProviderName()} account</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
)}
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{showOAuthModal && (
|
||||
<OAuthRequiredModal
|
||||
isOpen={showOAuthModal}
|
||||
onClose={() => setShowOAuthModal(false)}
|
||||
provider={provider}
|
||||
toolName={getProviderName()}
|
||||
requiredScopes={requiredScopes}
|
||||
serviceId={getServiceId()}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { splitReferenceSegment } from '@/lib/workflows/references'
|
||||
import { REFERENCE } from '@/executor/consts'
|
||||
import { createCombinedPattern } from '@/executor/utils/reference-validation'
|
||||
import { normalizeBlockName } from '@/stores/workflows/utils'
|
||||
|
||||
export interface HighlightContext {
|
||||
@@ -43,7 +45,9 @@ export function formatDisplayText(text: string, context?: HighlightContext): Rea
|
||||
}
|
||||
|
||||
const nodes: ReactNode[] = []
|
||||
const regex = /<[^>]+>|\{\{[^}]+\}\}/g
|
||||
// Match variable references without allowing nested brackets to prevent matching across references
|
||||
// e.g., "<3. text <real.ref>" should match "<3" and "<real.ref>", not the whole string
|
||||
const regex = createCombinedPattern()
|
||||
let lastIndex = 0
|
||||
let key = 0
|
||||
|
||||
@@ -61,7 +65,7 @@ export function formatDisplayText(text: string, context?: HighlightContext): Rea
|
||||
pushPlainText(text.slice(lastIndex, index))
|
||||
}
|
||||
|
||||
if (matchText.startsWith('{{')) {
|
||||
if (matchText.startsWith(REFERENCE.ENV_VAR_START)) {
|
||||
nodes.push(
|
||||
<span key={key++} className='text-[#34B5FF] dark:text-[#34B5FF]'>
|
||||
{matchText}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Badge } from '@/components/emcn'
|
||||
import { Input } from '@/components/emcn/components/input/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -50,12 +51,13 @@ interface InputMappingFieldProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
blockId: string
|
||||
subBlockId: string
|
||||
disabled: boolean
|
||||
accessiblePrefixes: Set<string> | undefined
|
||||
inputController: ReturnType<typeof useSubBlockInput>
|
||||
inputRefs: React.MutableRefObject<Map<string, HTMLInputElement>>
|
||||
overlayRefs: React.MutableRefObject<Map<string, HTMLDivElement>>
|
||||
inputRefs: React.RefObject<Map<string, HTMLInputElement>>
|
||||
overlayRefs: React.RefObject<Map<string, HTMLDivElement>>
|
||||
collapsed: boolean
|
||||
onToggleCollapse: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -169,6 +171,7 @@ export function InputMapping({
|
||||
|
||||
const [childInputFields, setChildInputFields] = useState<InputFormatField[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [collapsedFields, setCollapsedFields] = useState<Record<string, boolean>>({})
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true
|
||||
@@ -245,6 +248,13 @@ export function InputMapping({
|
||||
setMapping(updated)
|
||||
}
|
||||
|
||||
const toggleCollapse = (fieldName: string) => {
|
||||
setCollapsedFields((prev) => ({
|
||||
...prev,
|
||||
[fieldName]: !prev[fieldName],
|
||||
}))
|
||||
}
|
||||
|
||||
if (!selectedWorkflowId) {
|
||||
return (
|
||||
<div className='flex flex-col items-center justify-center rounded-[4px] border border-[var(--border-strong)] bg-[#1F1F1F] p-8 text-center'>
|
||||
@@ -278,12 +288,13 @@ export function InputMapping({
|
||||
value=''
|
||||
onChange={() => {}}
|
||||
blockId={blockId}
|
||||
subBlockId={subBlockId}
|
||||
disabled={true}
|
||||
accessiblePrefixes={accessiblePrefixes}
|
||||
inputController={inputController}
|
||||
inputRefs={inputRefs}
|
||||
overlayRefs={overlayRefs}
|
||||
collapsed={false}
|
||||
onToggleCollapse={() => {}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -303,12 +314,13 @@ export function InputMapping({
|
||||
value={valueObj[field.name] || ''}
|
||||
onChange={(value) => handleFieldUpdate(field.name, value)}
|
||||
blockId={blockId}
|
||||
subBlockId={subBlockId}
|
||||
disabled={isPreview || disabled}
|
||||
accessiblePrefixes={accessiblePrefixes}
|
||||
inputController={inputController}
|
||||
inputRefs={inputRefs}
|
||||
overlayRefs={overlayRefs}
|
||||
collapsed={collapsedFields[field.name] || false}
|
||||
onToggleCollapse={() => toggleCollapse(field.name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -326,12 +338,13 @@ function InputMappingField({
|
||||
value,
|
||||
onChange,
|
||||
blockId,
|
||||
subBlockId,
|
||||
disabled,
|
||||
accessiblePrefixes,
|
||||
inputController,
|
||||
inputRefs,
|
||||
overlayRefs,
|
||||
collapsed,
|
||||
onToggleCollapse,
|
||||
}: InputMappingFieldProps) {
|
||||
const fieldId = fieldName
|
||||
const fieldState = inputController.fieldHelpers.getFieldState(fieldId)
|
||||
@@ -354,64 +367,91 @@ function InputMappingField({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='group relative overflow-visible rounded-[4px] border border-[var(--border-strong)] bg-[#1F1F1F]'>
|
||||
<div className='flex items-center justify-between bg-transparent px-[10px] py-[5px]'>
|
||||
<Label className='font-medium text-[14px] text-[var(--text-tertiary)]'>{fieldName}</Label>
|
||||
{fieldType && (
|
||||
<span className='rounded-md bg-[#2A2A2A] px-1.5 py-0.5 font-mono text-[10px] text-[var(--text-tertiary)]'>
|
||||
{fieldType}
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-[4px] border border-[var(--border-strong)] bg-[#1F1F1F]',
|
||||
collapsed ? 'overflow-hidden' : 'overflow-visible'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className='flex cursor-pointer items-center justify-between bg-transparent px-[10px] py-[5px]'
|
||||
onClick={onToggleCollapse}
|
||||
>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
|
||||
<span className='block truncate font-medium text-[14px] text-[var(--text-tertiary)]'>
|
||||
{fieldName}
|
||||
</span>
|
||||
)}
|
||||
{fieldType && <Badge className='h-[20px] text-[13px]'>{fieldType}</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
<div className='relative w-full border-[var(--border-strong)] border-t bg-transparent'>
|
||||
<Input
|
||||
ref={(el) => {
|
||||
if (el) inputRefs.current.set(fieldId, el)
|
||||
}}
|
||||
className={cn(
|
||||
'allow-scroll !bg-transparent w-full overflow-auto rounded-none border-0 px-[10px] py-[8px] text-transparent caret-white [-ms-overflow-style:none] [scrollbar-width:none] placeholder:text-[var(--text-muted)] focus-visible:ring-0 focus-visible:ring-offset-0 [&::-webkit-scrollbar]:hidden'
|
||||
)}
|
||||
type='text'
|
||||
value={value}
|
||||
onChange={handlers.onChange}
|
||||
onKeyDown={handlers.onKeyDown}
|
||||
onScroll={handleScroll}
|
||||
onDrop={handlers.onDrop}
|
||||
onDragOver={handlers.onDragOver}
|
||||
autoComplete='off'
|
||||
disabled={disabled}
|
||||
/>
|
||||
<div
|
||||
ref={(el) => {
|
||||
if (el) overlayRefs.current.set(fieldId, el)
|
||||
}}
|
||||
className='pointer-events-none absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[10px] py-[8px] font-medium font-sans text-[#eeeeee] text-sm [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
|
||||
>
|
||||
<div className='min-w-fit whitespace-pre'>
|
||||
{formatDisplayText(value, {
|
||||
accessiblePrefixes,
|
||||
highlightAll: !accessiblePrefixes,
|
||||
})}
|
||||
|
||||
{!collapsed && (
|
||||
<div className='flex flex-col gap-[6px] border-[var(--border-strong)] border-t px-[10px] pt-[6px] pb-[10px]'>
|
||||
<div className='space-y-[4px]'>
|
||||
<Label className='text-[13px]'>Value</Label>
|
||||
<div className='relative'>
|
||||
<Input
|
||||
ref={(el) => {
|
||||
if (el) inputRefs.current.set(fieldId, el)
|
||||
}}
|
||||
name='value'
|
||||
value={value}
|
||||
onChange={handlers.onChange}
|
||||
onKeyDown={handlers.onKeyDown}
|
||||
onDrop={handlers.onDrop}
|
||||
onDragOver={handlers.onDragOver}
|
||||
onScroll={(e) => handleScroll(e)}
|
||||
onPaste={() =>
|
||||
setTimeout(() => {
|
||||
const input = inputRefs.current.get(fieldId)
|
||||
input && handleScroll({ currentTarget: input } as any)
|
||||
}, 0)
|
||||
}
|
||||
placeholder='Enter value or reference'
|
||||
disabled={disabled}
|
||||
autoComplete='off'
|
||||
className={cn(
|
||||
'allow-scroll w-full overflow-auto text-transparent caret-foreground'
|
||||
)}
|
||||
style={{ overflowX: 'auto' }}
|
||||
/>
|
||||
<div
|
||||
ref={(el) => {
|
||||
if (el) overlayRefs.current.set(fieldId, el)
|
||||
}}
|
||||
className='pointer-events-none absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[8px] py-[6px] font-medium font-sans text-sm'
|
||||
style={{ overflowX: 'auto' }}
|
||||
>
|
||||
<div
|
||||
className='w-full whitespace-pre'
|
||||
style={{ scrollbarWidth: 'none', minWidth: 'fit-content' }}
|
||||
>
|
||||
{formatDisplayText(
|
||||
value,
|
||||
accessiblePrefixes ? { accessiblePrefixes } : { highlightAll: true }
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{fieldState.showTags && (
|
||||
<TagDropdown
|
||||
visible={fieldState.showTags}
|
||||
onSelect={tagSelectHandler}
|
||||
blockId={blockId}
|
||||
activeSourceBlockId={fieldState.activeSourceBlockId}
|
||||
inputValue={value}
|
||||
cursorPosition={fieldState.cursorPosition}
|
||||
onClose={() => inputController.fieldHelpers.hideFieldDropdowns(fieldId)}
|
||||
inputRef={
|
||||
{
|
||||
current: inputRefs.current.get(fieldId) || null,
|
||||
} as React.RefObject<HTMLInputElement>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{fieldState.showTags && (
|
||||
<TagDropdown
|
||||
visible={fieldState.showTags}
|
||||
onSelect={tagSelectHandler}
|
||||
blockId={blockId}
|
||||
activeSourceBlockId={fieldState.activeSourceBlockId}
|
||||
inputValue={value}
|
||||
cursorPosition={fieldState.cursorPosition}
|
||||
onClose={() => inputController.fieldHelpers.hideFieldDropdowns(fieldId)}
|
||||
inputRef={
|
||||
{
|
||||
current: inputRefs.current.get(fieldId) || null,
|
||||
} as React.RefObject<HTMLInputElement>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Combobox, Input, Label, Textarea } from '@/components/emcn/components'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/formatted-text'
|
||||
@@ -174,7 +165,6 @@ function McpTextareaWithTags({
|
||||
onChange(newValue)
|
||||
setCursorPosition(newCursorPosition)
|
||||
|
||||
// Check for tag trigger
|
||||
const tagTrigger = checkTagTrigger(newValue, newCursorPosition)
|
||||
setShowTags(tagTrigger.show)
|
||||
}
|
||||
@@ -308,7 +298,6 @@ export function McpDynamicArgs({
|
||||
if (disabled) return
|
||||
|
||||
const current = currentArgs()
|
||||
// Store the value as-is, preserving types (number, boolean, etc.)
|
||||
const updated = { ...current, [paramName]: value }
|
||||
setToolArgs(updated)
|
||||
},
|
||||
@@ -357,29 +346,38 @@ export function McpDynamicArgs({
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'dropdown':
|
||||
case 'dropdown': {
|
||||
const dropdownOptions = useMemo(
|
||||
() =>
|
||||
(paramSchema.enum || []).map((option: any) => ({
|
||||
label: String(option),
|
||||
value: String(option),
|
||||
})),
|
||||
[paramSchema.enum]
|
||||
)
|
||||
|
||||
return (
|
||||
<div key={`${paramName}-dropdown`}>
|
||||
<Select
|
||||
<Combobox
|
||||
options={dropdownOptions}
|
||||
value={value || ''}
|
||||
onValueChange={(selectedValue) => updateParameter(paramName, selectedValue)}
|
||||
selectedValue={value || ''}
|
||||
onChange={(selectedValue) => {
|
||||
const matchedOption = dropdownOptions.find(
|
||||
(opt: { label: string; value: string }) => opt.value === selectedValue
|
||||
)
|
||||
if (matchedOption) {
|
||||
updateParameter(paramName, selectedValue)
|
||||
}
|
||||
}}
|
||||
placeholder={`Select ${formatParameterLabel(paramName).toLowerCase()}`}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className='w-full'>
|
||||
<SelectValue
|
||||
placeholder={`Select ${formatParameterLabel(paramName).toLowerCase()}`}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{paramSchema.enum?.map((option: any) => (
|
||||
<SelectItem key={String(option)} value={String(option)}>
|
||||
{String(option)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
editable={false}
|
||||
filterOptions={true}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
case 'slider': {
|
||||
const minValue = paramSchema.minimum ?? 0
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Check, ChevronDown, RefreshCw } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Combobox } from '@/components/emcn/components'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { useMcpServers } from '@/hooks/queries/mcp'
|
||||
@@ -34,7 +24,7 @@ export function McpServerSelector({
|
||||
}: McpServerSelectorProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const [open, setOpen] = useState(false)
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
|
||||
const { data: servers = [], isLoading, error } = useMcpServers(workspaceId)
|
||||
const enabledServers = servers.filter((s) => s.enabled && !s.deletedAt)
|
||||
@@ -48,87 +38,47 @@ export function McpServerSelector({
|
||||
|
||||
const selectedServer = enabledServers.find((server) => server.id === selectedServerId)
|
||||
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
setOpen(isOpen)
|
||||
// React Query automatically keeps server list fresh
|
||||
}
|
||||
const comboboxOptions = useMemo(
|
||||
() =>
|
||||
enabledServers.map((server) => ({
|
||||
label: server.name,
|
||||
value: server.id,
|
||||
})),
|
||||
[enabledServers]
|
||||
)
|
||||
|
||||
const handleSelect = (serverId: string) => {
|
||||
if (!isPreview) {
|
||||
setStoreValue(serverId)
|
||||
const handleComboboxChange = (value: string) => {
|
||||
const matchedServer = enabledServers.find((s) => s.id === value)
|
||||
if (matchedServer) {
|
||||
setInputValue(matchedServer.name)
|
||||
if (!isPreview) {
|
||||
setStoreValue(value)
|
||||
}
|
||||
} else {
|
||||
setInputValue(value)
|
||||
}
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const getDisplayText = () => {
|
||||
useEffect(() => {
|
||||
if (selectedServer) {
|
||||
return <span className='truncate font-normal'>{selectedServer.name}</span>
|
||||
setInputValue(selectedServer.name)
|
||||
} else {
|
||||
setInputValue('')
|
||||
}
|
||||
return <span className='truncate text-muted-foreground'>{label}</span>
|
||||
}
|
||||
}, [selectedServer])
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
role='combobox'
|
||||
aria-expanded={open}
|
||||
className='relative w-full justify-between'
|
||||
disabled={disabled}
|
||||
>
|
||||
<div className='flex max-w-[calc(100%-20px)] items-center overflow-hidden'>
|
||||
{getDisplayText()}
|
||||
</div>
|
||||
<ChevronDown className='absolute right-3 h-4 w-4 shrink-0 opacity-50' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-[250px] p-0' align='start'>
|
||||
<Command>
|
||||
<CommandInput placeholder='Search servers...' />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{isLoading ? (
|
||||
<div className='flex items-center justify-center p-4'>
|
||||
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||
<span className='ml-2'>Loading servers...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-destructive text-sm'>Error loading servers</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{error instanceof Error ? error.message : 'Unknown error'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No MCP servers found</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Configure MCP servers in workspace settings
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CommandEmpty>
|
||||
{enabledServers.length > 0 && (
|
||||
<CommandGroup>
|
||||
{enabledServers.map((server) => (
|
||||
<CommandItem
|
||||
key={server.id}
|
||||
value={`server-${server.id}-${server.name}`}
|
||||
onSelect={() => handleSelect(server.id)}
|
||||
className='cursor-pointer'
|
||||
>
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
<span className='truncate font-normal'>{server.name}</span>
|
||||
</div>
|
||||
{server.id === selectedServerId && <Check className='ml-auto h-4 w-4' />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Combobox
|
||||
options={comboboxOptions}
|
||||
value={inputValue}
|
||||
selectedValue={selectedServerId}
|
||||
onChange={handleComboboxChange}
|
||||
placeholder={label}
|
||||
disabled={disabled}
|
||||
editable={true}
|
||||
filterOptions={true}
|
||||
isLoading={isLoading}
|
||||
error={error instanceof Error ? error.message : null}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Check, ChevronDown, RefreshCw } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Combobox } from '@/components/emcn/components'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { useMcpTools } from '@/hooks/use-mcp-tools'
|
||||
@@ -34,7 +24,7 @@ export function McpToolSelector({
|
||||
}: McpToolSelectorProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const [open, setOpen] = useState(false)
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
|
||||
const { mcpTools, isLoading, error, refreshTools, getToolsByServer } = useMcpTools(workspaceId)
|
||||
|
||||
@@ -73,105 +63,59 @@ export function McpToolSelector({
|
||||
}
|
||||
}, [serverValue, availableTools, storeValue, setStoreValue, isPreview, disabled])
|
||||
|
||||
const comboboxOptions = useMemo(
|
||||
() =>
|
||||
availableTools.map((tool) => ({
|
||||
label: tool.name,
|
||||
value: tool.id,
|
||||
})),
|
||||
[availableTools]
|
||||
)
|
||||
|
||||
const handleComboboxChange = (value: string) => {
|
||||
const matchedTool = availableTools.find((t) => t.id === value)
|
||||
if (matchedTool) {
|
||||
setInputValue(matchedTool.name)
|
||||
if (!isPreview) {
|
||||
setStoreValue(value)
|
||||
if (matchedTool.inputSchema) {
|
||||
setSchemaCache(matchedTool.inputSchema)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setInputValue(value)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
setOpen(isOpen)
|
||||
if (isOpen && serverValue) {
|
||||
refreshTools()
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelect = (toolId: string) => {
|
||||
if (!isPreview) {
|
||||
setStoreValue(toolId)
|
||||
|
||||
const tool = availableTools.find((t) => t.id === toolId)
|
||||
if (tool?.inputSchema) {
|
||||
setSchemaCache(tool.inputSchema)
|
||||
}
|
||||
}
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const getDisplayText = () => {
|
||||
useEffect(() => {
|
||||
if (selectedTool) {
|
||||
return <span className='truncate font-normal'>{selectedTool.name}</span>
|
||||
setInputValue(selectedTool.name)
|
||||
} else {
|
||||
setInputValue('')
|
||||
}
|
||||
return (
|
||||
<span className='truncate text-muted-foreground'>
|
||||
{serverValue ? label : 'Select server first'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
}, [selectedTool])
|
||||
|
||||
const isDisabled = disabled || !serverValue
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
role='combobox'
|
||||
aria-expanded={open}
|
||||
className='relative w-full justify-between'
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<div className='flex max-w-[calc(100%-20px)] items-center overflow-hidden'>
|
||||
{getDisplayText()}
|
||||
</div>
|
||||
<ChevronDown className='absolute right-3 h-4 w-4 shrink-0 opacity-50' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-[250px] p-0' align='start'>
|
||||
<Command>
|
||||
<CommandInput placeholder='Search tools...' />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{isLoading ? (
|
||||
<div className='flex items-center justify-center p-4'>
|
||||
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||
<span className='ml-2'>Loading tools...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-destructive text-sm'>Error loading tools</p>
|
||||
<p className='text-muted-foreground text-xs'>{error}</p>
|
||||
</div>
|
||||
) : !serverValue ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No server selected</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Select an MCP server first to see available tools
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No tools found</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
The selected server has no available tools
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CommandEmpty>
|
||||
{availableTools.length > 0 && (
|
||||
<CommandGroup>
|
||||
{availableTools.map((tool) => (
|
||||
<CommandItem
|
||||
key={tool.id}
|
||||
value={`tool-${tool.id}-${tool.name}`}
|
||||
onSelect={() => handleSelect(tool.id)}
|
||||
className='cursor-pointer'
|
||||
>
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
<span className='truncate font-normal'>{tool.name}</span>
|
||||
</div>
|
||||
{tool.id === selectedToolId && <Check className='ml-auto h-4 w-4' />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Combobox
|
||||
options={comboboxOptions}
|
||||
value={inputValue}
|
||||
selectedValue={selectedToolId}
|
||||
onChange={handleComboboxChange}
|
||||
onOpenChange={handleOpenChange}
|
||||
placeholder={serverValue ? label : 'Select server first'}
|
||||
disabled={isDisabled}
|
||||
editable={true}
|
||||
filterOptions={true}
|
||||
isLoading={isLoading}
|
||||
error={error || null}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,638 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Check, ChevronDown, ExternalLink, RefreshCw, X } from 'lucide-react'
|
||||
import { JiraIcon } from '@/components/icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
type Credential,
|
||||
getProviderIdFromServiceId,
|
||||
getServiceIdFromScopes,
|
||||
type OAuthProvider,
|
||||
} from '@/lib/oauth'
|
||||
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
|
||||
import { useDisplayNamesStore } from '@/stores/display-names/store'
|
||||
|
||||
const logger = createLogger('JiraProjectSelector')
|
||||
|
||||
export interface JiraProjectInfo {
|
||||
id: string
|
||||
key: string
|
||||
name: string
|
||||
url?: string
|
||||
avatarUrl?: string
|
||||
description?: string
|
||||
projectTypeKey?: string
|
||||
simplified?: boolean
|
||||
style?: string
|
||||
isPrivate?: boolean
|
||||
}
|
||||
|
||||
interface JiraProjectSelectorProps {
|
||||
value: string
|
||||
onChange: (value: string, projectInfo?: JiraProjectInfo) => void
|
||||
provider: OAuthProvider
|
||||
requiredScopes?: string[]
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
serviceId?: string
|
||||
domain: string
|
||||
showPreview?: boolean
|
||||
onProjectInfoChange?: (projectInfo: JiraProjectInfo | null) => void
|
||||
credentialId?: string
|
||||
isForeignCredential?: boolean
|
||||
workflowId?: string
|
||||
}
|
||||
|
||||
export function JiraProjectSelector({
|
||||
value,
|
||||
onChange,
|
||||
provider,
|
||||
requiredScopes = [],
|
||||
label = 'Select Jira project',
|
||||
disabled = false,
|
||||
serviceId,
|
||||
domain,
|
||||
showPreview = true,
|
||||
onProjectInfoChange,
|
||||
credentialId,
|
||||
isForeignCredential = false,
|
||||
workflowId,
|
||||
}: JiraProjectSelectorProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [credentials, setCredentials] = useState<Credential[]>([])
|
||||
const [projects, setProjects] = useState<JiraProjectInfo[]>([])
|
||||
const [selectedCredentialId, setSelectedCredentialId] = useState<string>(credentialId || '')
|
||||
const [selectedProjectId, setSelectedProjectId] = useState(value)
|
||||
const [selectedProject, setSelectedProject] = useState<JiraProjectInfo | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [showOAuthModal, setShowOAuthModal] = useState(false)
|
||||
const initialFetchRef = useRef(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [cloudId, setCloudId] = useState<string | null>(null)
|
||||
|
||||
// Get cached display name
|
||||
const cachedProjectName = useDisplayNamesStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
const effectiveCredentialId = credentialId || selectedCredentialId
|
||||
if (!effectiveCredentialId || !value) return null
|
||||
return state.cache.projects[`jira-${effectiveCredentialId}`]?.[value] || null
|
||||
},
|
||||
[credentialId, selectedCredentialId, value]
|
||||
)
|
||||
)
|
||||
|
||||
// Handle search with debounce
|
||||
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
// Clear any existing timeout
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current)
|
||||
}
|
||||
|
||||
// Set a new timeout
|
||||
searchTimeoutRef.current = setTimeout(() => {
|
||||
if (value.length >= 1) {
|
||||
fetchProjects(value)
|
||||
} else {
|
||||
fetchProjects() // Fetch all projects if no search term
|
||||
}
|
||||
}, 500) // 500ms debounce
|
||||
}
|
||||
|
||||
// Clean up the timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Determine the appropriate service ID based on provider and scopes
|
||||
const getServiceId = (): string => {
|
||||
if (serviceId) return serviceId
|
||||
return getServiceIdFromScopes(provider, requiredScopes)
|
||||
}
|
||||
|
||||
// Determine the appropriate provider ID based on service and scopes (stabilized)
|
||||
const providerId = useMemo(() => {
|
||||
const effectiveServiceId = getServiceId()
|
||||
return getProviderIdFromServiceId(effectiveServiceId)
|
||||
}, [serviceId, provider, requiredScopes])
|
||||
|
||||
// Fetch available credentials for this provider
|
||||
const fetchCredentials = useCallback(async () => {
|
||||
if (!providerId) return
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const response = await fetch(`/api/auth/oauth/credentials?provider=${providerId}`)
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setCredentials(data.credentials)
|
||||
// Do not auto-select credentials. Only use the credentialId provided by the parent.
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching credentials:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [providerId])
|
||||
|
||||
// Fetch detailed project information
|
||||
const fetchProjectInfo = useCallback(
|
||||
async (projectId: string) => {
|
||||
if (!selectedCredentialId || !domain || !projectId) return
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// Get the access token from the selected credential
|
||||
const tokenResponse = await fetch('/api/auth/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
credentialId: selectedCredentialId,
|
||||
workflowId,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorData = await tokenResponse.json()
|
||||
logger.error('Access token error:', errorData)
|
||||
setError('Authentication failed. Please reconnect your Jira account.')
|
||||
return
|
||||
}
|
||||
|
||||
const tokenData = await tokenResponse.json()
|
||||
const accessToken = tokenData.accessToken
|
||||
|
||||
if (!accessToken) {
|
||||
logger.error('No access token returned')
|
||||
setError('Authentication failed. Please reconnect your Jira account.')
|
||||
return
|
||||
}
|
||||
|
||||
// Use POST /api/tools/jira/projects to fetch a single project by id
|
||||
const response = await fetch(`/api/tools/jira/projects`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domain, accessToken, projectId, cloudId }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
logger.error('Jira API error:', errorData)
|
||||
throw new Error(errorData.error || 'Failed to fetch project details')
|
||||
}
|
||||
|
||||
const json = await response.json()
|
||||
const projectInfo = json?.project
|
||||
const newCloudId = json?.cloudId
|
||||
|
||||
if (newCloudId) {
|
||||
setCloudId(newCloudId)
|
||||
}
|
||||
|
||||
if (projectInfo) {
|
||||
setSelectedProject(projectInfo)
|
||||
onProjectInfoChange?.(projectInfo)
|
||||
} else {
|
||||
setSelectedProject(null)
|
||||
onProjectInfoChange?.(null)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching project details:', error)
|
||||
setError((error as Error).message)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
},
|
||||
[selectedCredentialId, domain, onProjectInfoChange, cloudId]
|
||||
)
|
||||
|
||||
// Fetch projects from Jira
|
||||
const fetchProjects = useCallback(
|
||||
async (searchQuery?: string) => {
|
||||
if (!selectedCredentialId || !domain) return
|
||||
|
||||
// Validate domain format
|
||||
const trimmedDomain = domain.trim().toLowerCase()
|
||||
if (!trimmedDomain.includes('.')) {
|
||||
setError(
|
||||
'Invalid domain format. Please provide the full domain (e.g., your-site.atlassian.net)'
|
||||
)
|
||||
setProjects([])
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// Get the access token from the selected credential
|
||||
const tokenResponse = await fetch('/api/auth/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
credentialId: selectedCredentialId,
|
||||
workflowId,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorData = await tokenResponse.json()
|
||||
logger.error('Access token error:', errorData)
|
||||
setError('Authentication failed. Please reconnect your Jira account.')
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const tokenData = await tokenResponse.json()
|
||||
const accessToken = tokenData.accessToken
|
||||
|
||||
if (!accessToken) {
|
||||
logger.error('No access token returned')
|
||||
setError('Authentication failed. Please reconnect your Jira account.')
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Build query parameters for the projects endpoint
|
||||
const queryParams = new URLSearchParams({
|
||||
domain,
|
||||
accessToken,
|
||||
...(searchQuery && { query: searchQuery }),
|
||||
...(cloudId && { cloudId }),
|
||||
})
|
||||
|
||||
// Use the GET endpoint for project search
|
||||
const response = await fetch(`/api/tools/jira/projects?${queryParams.toString()}`)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
logger.error('Jira API error:', errorData)
|
||||
throw new Error(errorData.error || 'Failed to fetch projects')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.cloudId) {
|
||||
setCloudId(data.cloudId)
|
||||
}
|
||||
|
||||
// Process the projects results
|
||||
const foundProjects = data.projects || []
|
||||
logger.info(`Received ${foundProjects.length} projects from API`)
|
||||
setProjects(foundProjects)
|
||||
|
||||
// Cache project names in display names store
|
||||
if (selectedCredentialId && foundProjects.length > 0) {
|
||||
const projectMap = foundProjects.reduce(
|
||||
(acc: Record<string, string>, proj: JiraProjectInfo) => {
|
||||
acc[proj.id] = proj.name
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
useDisplayNamesStore
|
||||
.getState()
|
||||
.setDisplayNames('projects', `jira-${selectedCredentialId}`, projectMap)
|
||||
}
|
||||
|
||||
// If we have a selected project ID, find the project info
|
||||
if (selectedProjectId) {
|
||||
const projectInfo = foundProjects.find(
|
||||
(project: JiraProjectInfo) => project.id === selectedProjectId
|
||||
)
|
||||
if (projectInfo) {
|
||||
setSelectedProject(projectInfo)
|
||||
onProjectInfoChange?.(projectInfo)
|
||||
} else if (!searchQuery && selectedProjectId) {
|
||||
// If we can't find the project in the list, try to fetch it directly
|
||||
fetchProjectInfo(selectedProjectId)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching projects:', error)
|
||||
setError((error as Error).message)
|
||||
setProjects([])
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
},
|
||||
[
|
||||
selectedCredentialId,
|
||||
domain,
|
||||
selectedProjectId,
|
||||
onProjectInfoChange,
|
||||
fetchProjectInfo,
|
||||
cloudId,
|
||||
]
|
||||
)
|
||||
|
||||
// Fetch credentials list when dropdown opens (for account switching UI), not on mount
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchCredentials()
|
||||
}
|
||||
}, [open, fetchCredentials])
|
||||
|
||||
// Keep local credential state in sync with persisted credential
|
||||
useEffect(() => {
|
||||
if (credentialId && credentialId !== selectedCredentialId) {
|
||||
setSelectedCredentialId(credentialId)
|
||||
}
|
||||
}, [credentialId, selectedCredentialId])
|
||||
|
||||
// Keep internal selectedProjectId in sync with the value prop
|
||||
useEffect(() => {
|
||||
if (value !== selectedProjectId) {
|
||||
setSelectedProjectId(value)
|
||||
}
|
||||
}, [value, selectedProjectId])
|
||||
|
||||
// Clear callback when value is cleared
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
setSelectedProject(null)
|
||||
onProjectInfoChange?.(null)
|
||||
}
|
||||
}, [value, onProjectInfoChange])
|
||||
|
||||
// Fetch project info on mount if we have a value but no selectedProject state
|
||||
useEffect(() => {
|
||||
if (value && selectedCredentialId && domain && !selectedProject) {
|
||||
fetchProjectInfo(value)
|
||||
}
|
||||
}, [value, selectedCredentialId, domain, selectedProject, fetchProjectInfo])
|
||||
|
||||
// Handle open change
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
setOpen(isOpen)
|
||||
// Only fetch projects when a credential is present; otherwise, do nothing
|
||||
if (isOpen && selectedCredentialId && domain && domain.includes('.')) {
|
||||
fetchProjects('')
|
||||
}
|
||||
}
|
||||
|
||||
// Handle project selection
|
||||
const handleSelectProject = (project: JiraProjectInfo) => {
|
||||
setSelectedProjectId(project.id)
|
||||
setSelectedProject(project)
|
||||
onChange(project.id, project)
|
||||
onProjectInfoChange?.(project)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
// Handle adding a new credential
|
||||
const handleAddCredential = () => {
|
||||
// Show the OAuth modal
|
||||
setShowOAuthModal(true)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
// Clear selection
|
||||
const handleClearSelection = () => {
|
||||
setSelectedProjectId('')
|
||||
setSelectedProject(null)
|
||||
setError(null)
|
||||
onChange('', undefined)
|
||||
onProjectInfoChange?.(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-2'>
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
role='combobox'
|
||||
aria-expanded={open}
|
||||
className='w-full justify-between'
|
||||
disabled={disabled || !domain || !selectedCredentialId || isForeignCredential}
|
||||
>
|
||||
{cachedProjectName ? (
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
<span className='truncate font-normal'>{cachedProjectName}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex items-center gap-2'>
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
<span className='text-muted-foreground'>{label}</span>
|
||||
</div>
|
||||
)}
|
||||
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
{!isForeignCredential && (
|
||||
<PopoverContent className='w-[300px] p-0' align='start'>
|
||||
{selectedCredentialId && credentials.length > 0 && (
|
||||
<div className='flex items-center justify-between border-b px-3 py-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
|
||||
'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
{credentials.length > 1 && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 px-2 text-xs'
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Switch
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Command>
|
||||
<CommandInput placeholder='Search projects...' onValueChange={handleSearch} />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{isLoading ? (
|
||||
<div className='flex items-center justify-center p-4'>
|
||||
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||
<span className='ml-2'>Loading projects...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='text-destructive text-sm'>{error}</p>
|
||||
</div>
|
||||
) : credentials.length === 0 ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No accounts connected.</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Connect a Jira account to continue.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No projects found.</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Try a different search or account.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CommandEmpty>
|
||||
|
||||
{/* Account selection - only show if we have multiple accounts */}
|
||||
{credentials.length > 1 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
Switch Account
|
||||
</div>
|
||||
{credentials.map((cred) => (
|
||||
<CommandItem
|
||||
key={cred.id}
|
||||
value={`account-${cred.id}`}
|
||||
onSelect={() => setSelectedCredentialId(cred.id)}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
<span className='font-normal'>{cred.name}</span>
|
||||
</div>
|
||||
{cred.id === selectedCredentialId && (
|
||||
<Check className='ml-auto h-4 w-4' />
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Projects list */}
|
||||
{projects.length > 0 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
Projects
|
||||
</div>
|
||||
{projects.map((project) => (
|
||||
<CommandItem
|
||||
key={project.id}
|
||||
value={`project-${project.id}-${project.name}`}
|
||||
onSelect={() => handleSelectProject(project)}
|
||||
>
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
{project.avatarUrl ? (
|
||||
<img
|
||||
src={project.avatarUrl}
|
||||
alt={project.name}
|
||||
className='h-4 w-4 rounded'
|
||||
/>
|
||||
) : (
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
)}
|
||||
<span className='truncate font-normal'>{project.name}</span>
|
||||
</div>
|
||||
{project.id === selectedProjectId && (
|
||||
<Check className='ml-auto h-4 w-4' />
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Connect account option - only show if no credentials */}
|
||||
{credentials.length === 0 && (
|
||||
<CommandGroup>
|
||||
<CommandItem onSelect={handleAddCredential}>
|
||||
<div className='flex items-center gap-2 text-foreground'>
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
<span>Connect Jira account</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
)}
|
||||
</Popover>
|
||||
|
||||
{/* Project preview */}
|
||||
{showPreview && selectedProject && (
|
||||
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
|
||||
<div className='absolute top-2 right-2'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-5 w-5 hover:bg-muted'
|
||||
onClick={handleClearSelection}
|
||||
>
|
||||
<X className='h-3 w-3' />
|
||||
</Button>
|
||||
</div>
|
||||
<div className='flex items-center gap-3 pr-4'>
|
||||
<div className='flex h-6 w-6 flex-shrink-0 items-center justify-center rounded bg-muted/20'>
|
||||
{selectedProject.avatarUrl ? (
|
||||
<img
|
||||
src={selectedProject.avatarUrl}
|
||||
alt={selectedProject.name}
|
||||
className='h-6 w-6 rounded'
|
||||
/>
|
||||
) : (
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
)}
|
||||
</div>
|
||||
<div className='min-w-0 flex-1 overflow-hidden'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<h4 className='truncate font-medium text-xs'>{selectedProject.name}</h4>
|
||||
<span className='whitespace-nowrap text-muted-foreground text-xs'>
|
||||
{selectedProject.key}
|
||||
</span>
|
||||
</div>
|
||||
{selectedProject.url ? (
|
||||
<a
|
||||
href={selectedProject.url}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='flex items-center gap-1 text-foreground text-xs hover:underline'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span>Open in Jira</span>
|
||||
<ExternalLink className='h-3 w-3' />
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showOAuthModal && (
|
||||
<OAuthRequiredModal
|
||||
isOpen={showOAuthModal}
|
||||
onClose={() => setShowOAuthModal(false)}
|
||||
provider={provider}
|
||||
toolName='Jira'
|
||||
requiredScopes={requiredScopes}
|
||||
serviceId={getServiceId()}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Check, ChevronDown, RefreshCw } from 'lucide-react'
|
||||
import { LinearIcon } from '@/components/icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { useDisplayNamesStore } from '@/stores/display-names/store'
|
||||
|
||||
export interface LinearProjectInfo {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface LinearProjectSelectorProps {
|
||||
value: string
|
||||
onChange: (projectId: string, projectInfo?: LinearProjectInfo) => void
|
||||
credential: string
|
||||
teamId: string
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
workflowId?: string
|
||||
}
|
||||
|
||||
export function LinearProjectSelector({
|
||||
value,
|
||||
onChange,
|
||||
credential,
|
||||
teamId,
|
||||
label = 'Select Linear project',
|
||||
disabled = false,
|
||||
workflowId,
|
||||
}: LinearProjectSelectorProps) {
|
||||
const [projects, setProjects] = useState<LinearProjectInfo[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
// Get cached display name
|
||||
const cachedProjectName = useDisplayNamesStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (!credential || !value) return null
|
||||
return state.cache.projects[`linear-${credential}`]?.[value] || null
|
||||
},
|
||||
[credential, value]
|
||||
)
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!credential || !teamId) return
|
||||
const controller = new AbortController()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
fetch('/api/tools/linear/projects', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ credential, teamId, workflowId }),
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text()
|
||||
throw new Error(`HTTP error! status: ${res.status} - ${errorText}`)
|
||||
}
|
||||
return res.json()
|
||||
})
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
setError(data.error)
|
||||
setProjects([])
|
||||
} else {
|
||||
setProjects(data.projects)
|
||||
|
||||
// Cache project names in display names store
|
||||
if (credential && data.projects) {
|
||||
const projectMap = data.projects.reduce(
|
||||
(acc: Record<string, string>, proj: LinearProjectInfo) => {
|
||||
acc[proj.id] = proj.name
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
useDisplayNamesStore
|
||||
.getState()
|
||||
.setDisplayNames('projects', `linear-${credential}`, projectMap)
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.name === 'AbortError') return
|
||||
setError(err.message)
|
||||
setProjects([])
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
return () => controller.abort()
|
||||
}, [credential, teamId, value, workflowId])
|
||||
|
||||
const handleSelectProject = (project: LinearProjectInfo) => {
|
||||
onChange(project.id, project)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
setOpen(isOpen)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
role='combobox'
|
||||
aria-expanded={open}
|
||||
className='w-full justify-between'
|
||||
disabled={disabled || !credential || !teamId}
|
||||
>
|
||||
{cachedProjectName ? (
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
<LinearIcon className='h-4 w-4' />
|
||||
<span className='truncate font-normal'>{cachedProjectName}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex items-center gap-2'>
|
||||
<LinearIcon className='h-4 w-4' />
|
||||
<span className='text-muted-foreground'>{label}</span>
|
||||
</div>
|
||||
)}
|
||||
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-[300px] p-0' align='start'>
|
||||
<Command>
|
||||
<CommandInput placeholder='Search projects...' />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{loading ? (
|
||||
<div className='flex items-center justify-center p-4'>
|
||||
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||
<span className='ml-2'>Loading projects...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='text-destructive text-sm'>{error}</p>
|
||||
</div>
|
||||
) : !credential || !teamId ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>Missing credentials or team</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Please configure Linear credentials and select a team.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No projects found</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
No projects available for the selected team.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CommandEmpty>
|
||||
|
||||
{projects.length > 0 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
Projects
|
||||
</div>
|
||||
{projects.map((project) => (
|
||||
<CommandItem
|
||||
key={project.id}
|
||||
value={`project-${project.id}-${project.name}`}
|
||||
onSelect={() => handleSelectProject(project)}
|
||||
className='cursor-pointer'
|
||||
>
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
<LinearIcon className='h-4 w-4' />
|
||||
<span className='truncate font-normal'>{project.name}</span>
|
||||
</div>
|
||||
{project.id === value && <Check className='ml-auto h-4 w-4' />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Check, ChevronDown, RefreshCw } from 'lucide-react'
|
||||
import { LinearIcon } from '@/components/icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { useDisplayNamesStore } from '@/stores/display-names/store'
|
||||
|
||||
export interface LinearTeamInfo {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface LinearTeamSelectorProps {
|
||||
value: string
|
||||
onChange: (teamId: string, teamInfo?: LinearTeamInfo) => void
|
||||
credential: string
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
workflowId?: string
|
||||
showPreview?: boolean
|
||||
}
|
||||
|
||||
export function LinearTeamSelector({
|
||||
value,
|
||||
onChange,
|
||||
credential,
|
||||
label = 'Select Linear team',
|
||||
disabled = false,
|
||||
workflowId,
|
||||
}: LinearTeamSelectorProps) {
|
||||
const [teams, setTeams] = useState<LinearTeamInfo[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
// Get cached display name
|
||||
const cachedTeamName = useDisplayNamesStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (!credential || !value) return null
|
||||
return state.cache.projects[`linear-${credential}`]?.[value] || null
|
||||
},
|
||||
[credential, value]
|
||||
)
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!credential) return
|
||||
const controller = new AbortController()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
fetch('/api/tools/linear/teams', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ credential, workflowId }),
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`)
|
||||
return res.json()
|
||||
})
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
setError(data.error)
|
||||
setTeams([])
|
||||
} else {
|
||||
setTeams(data.teams)
|
||||
|
||||
// Cache team names in display names store
|
||||
if (credential && data.teams) {
|
||||
const teamMap = data.teams.reduce(
|
||||
(acc: Record<string, string>, team: LinearTeamInfo) => {
|
||||
acc[team.id] = team.name
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
useDisplayNamesStore
|
||||
.getState()
|
||||
.setDisplayNames('projects', `linear-${credential}`, teamMap)
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.name === 'AbortError') return
|
||||
setError(err.message)
|
||||
setTeams([])
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
return () => controller.abort()
|
||||
}, [credential, value, workflowId])
|
||||
|
||||
const handleSelectTeam = (team: LinearTeamInfo) => {
|
||||
onChange(team.id, team)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
setOpen(isOpen)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
role='combobox'
|
||||
aria-expanded={open}
|
||||
className='w-full justify-between'
|
||||
disabled={disabled || !credential}
|
||||
>
|
||||
{cachedTeamName ? (
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
<LinearIcon className='h-4 w-4' />
|
||||
<span className='truncate font-normal'>{cachedTeamName}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex items-center gap-2'>
|
||||
<LinearIcon className='h-4 w-4' />
|
||||
<span className='text-muted-foreground'>{label}</span>
|
||||
</div>
|
||||
)}
|
||||
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-[300px] p-0' align='start'>
|
||||
<Command>
|
||||
<CommandInput placeholder='Search teams...' />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{loading ? (
|
||||
<div className='flex items-center justify-center p-4'>
|
||||
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||
<span className='ml-2'>Loading teams...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='text-destructive text-sm'>{error}</p>
|
||||
</div>
|
||||
) : !credential ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>Missing credentials</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Please configure Linear credentials.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No teams found</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
No teams available for this Linear account.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CommandEmpty>
|
||||
|
||||
{teams.length > 0 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>Teams</div>
|
||||
{teams.map((team) => (
|
||||
<CommandItem
|
||||
key={team.id}
|
||||
value={`team-${team.id}-${team.name}`}
|
||||
onSelect={() => handleSelectTeam(team)}
|
||||
className='cursor-pointer'
|
||||
>
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
<LinearIcon className='h-4 w-4' />
|
||||
<span className='truncate font-normal'>{team.name}</span>
|
||||
</div>
|
||||
{team.id === value && <Check className='ml-auto h-4 w-4' />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -1,23 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Tooltip } from '@/components/emcn'
|
||||
import {
|
||||
type JiraProjectInfo,
|
||||
JiraProjectSelector,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/project-selector/components/jira-project-selector'
|
||||
import {
|
||||
type LinearProjectInfo,
|
||||
LinearProjectSelector,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/project-selector/components/linear-project-selector'
|
||||
import {
|
||||
type LinearTeamInfo,
|
||||
LinearTeamSelector,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/project-selector/components/linear-team-selector'
|
||||
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
|
||||
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-depends-on-gate'
|
||||
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-foreign-credential'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { resolveSelectorForSubBlock } from '@/hooks/selectors/resolution'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
@@ -41,10 +32,10 @@ export function ProjectSelectorInput({
|
||||
previewContextValues,
|
||||
}: ProjectSelectorInputProps) {
|
||||
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
|
||||
const params = useParams()
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string>('')
|
||||
const [_projectInfo, setProjectInfo] = useState<any | null>(null)
|
||||
// Use the proper hook to get the current value and setter
|
||||
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
|
||||
const [storeValue] = useSubBlockValue(blockId, subBlock.id)
|
||||
const [connectedCredentialFromStore] = useSubBlockValue(blockId, 'credential')
|
||||
const [linearTeamIdFromStore] = useSubBlockValue(blockId, 'teamId')
|
||||
const [jiraDomainFromStore] = useSubBlockValue(blockId, 'domain')
|
||||
@@ -60,6 +51,7 @@ export function ProjectSelectorInput({
|
||||
(connectedCredential as string) || ''
|
||||
)
|
||||
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId) as string | null
|
||||
const workflowIdFromUrl = (params?.workflowId as string) || activeWorkflowId || ''
|
||||
const { finalDisabled } = useDependsOnGate(blockId, subBlock, {
|
||||
disabled,
|
||||
isPreview,
|
||||
@@ -87,91 +79,58 @@ export function ProjectSelectorInput({
|
||||
}
|
||||
}, [isPreview, previewValue, storeValue])
|
||||
|
||||
// Handle project selection
|
||||
const handleProjectChange = (
|
||||
projectId: string,
|
||||
info?: JiraProjectInfo | LinearTeamInfo | LinearProjectInfo
|
||||
) => {
|
||||
setSelectedProjectId(projectId)
|
||||
setProjectInfo(info || null)
|
||||
setStoreValue(projectId)
|
||||
const selectorResolution = useMemo(() => {
|
||||
return resolveSelectorForSubBlock(subBlock, {
|
||||
workflowId: workflowIdFromUrl || undefined,
|
||||
credentialId: (isLinear ? linearCredential : jiraCredential) as string | undefined,
|
||||
domain,
|
||||
teamId: (linearTeamId as string) || undefined,
|
||||
})
|
||||
}, [
|
||||
subBlock,
|
||||
workflowIdFromUrl,
|
||||
isLinear,
|
||||
linearCredential,
|
||||
jiraCredential,
|
||||
domain,
|
||||
linearTeamId,
|
||||
])
|
||||
|
||||
onProjectSelect?.(projectId)
|
||||
const missingCredential = !selectorResolution?.context.credentialId
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
setSelectedProjectId(value)
|
||||
onProjectSelect?.(value)
|
||||
}
|
||||
|
||||
// Discord no longer uses a server selector; fall through to other providers
|
||||
|
||||
// Render Linear team/project selector if provider is linear
|
||||
if (isLinear) {
|
||||
return (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div className='w-full'>
|
||||
{subBlock.id === 'teamId' ? (
|
||||
<LinearTeamSelector
|
||||
value={selectedProjectId}
|
||||
onChange={(teamId: string, teamInfo?: LinearTeamInfo) => {
|
||||
handleProjectChange(teamId, teamInfo)
|
||||
}}
|
||||
credential={(linearCredential as string) || ''}
|
||||
label={subBlock.placeholder || 'Select Linear team'}
|
||||
disabled={finalDisabled}
|
||||
showPreview={true}
|
||||
workflowId={activeWorkflowId || ''}
|
||||
/>
|
||||
) : (
|
||||
(() => {
|
||||
const credential = (linearCredential as string) || ''
|
||||
const teamId = (linearTeamId as string) || ''
|
||||
const isDisabled = finalDisabled
|
||||
return (
|
||||
<LinearProjectSelector
|
||||
value={selectedProjectId}
|
||||
onChange={(projectId: string, projectInfo?: LinearProjectInfo) => {
|
||||
handleProjectChange(projectId, projectInfo)
|
||||
}}
|
||||
credential={credential}
|
||||
teamId={teamId}
|
||||
label={subBlock.placeholder || 'Select Linear project'}
|
||||
disabled={isDisabled}
|
||||
workflowId={activeWorkflowId || ''}
|
||||
/>
|
||||
)
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
{!(linearCredential as string) && (
|
||||
<Tooltip.Content side='top'>
|
||||
<p>Please select a Linear account first</p>
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
)
|
||||
}
|
||||
|
||||
// Default to Jira project selector
|
||||
return (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div className='w-full'>
|
||||
<JiraProjectSelector
|
||||
value={selectedProjectId}
|
||||
onChange={handleProjectChange}
|
||||
domain={domain}
|
||||
provider='jira'
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
serviceId={subBlock.serviceId}
|
||||
label={subBlock.placeholder || 'Select Jira project'}
|
||||
disabled={finalDisabled}
|
||||
showPreview={true}
|
||||
onProjectInfoChange={setProjectInfo}
|
||||
credentialId={(jiraCredential as string) || ''}
|
||||
isForeignCredential={isForeignCredential}
|
||||
workflowId={activeWorkflowId || ''}
|
||||
/>
|
||||
{selectorResolution?.key ? (
|
||||
<SelectorCombobox
|
||||
blockId={blockId}
|
||||
subBlock={subBlock}
|
||||
selectorKey={selectorResolution.key}
|
||||
selectorContext={selectorResolution.context}
|
||||
disabled={finalDisabled || isForeignCredential || missingCredential}
|
||||
isPreview={isPreview}
|
||||
previewValue={previewValue ?? null}
|
||||
placeholder={subBlock.placeholder || 'Select project'}
|
||||
onOptionChange={handleChange}
|
||||
/>
|
||||
) : (
|
||||
<div className='w-full rounded border border-dashed p-4 text-center text-muted-foreground text-sm'>
|
||||
Project selector not supported for provider: {subBlock.provider || 'unknown'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
{missingCredential && (
|
||||
<Tooltip.Content side='top'>
|
||||
<p>Please select an account first</p>
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import type React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Combobox as EditableCombobox } from '@/components/emcn/components'
|
||||
import { SubBlockInputController } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/sub-block-input-controller'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types'
|
||||
import {
|
||||
useSelectorOptionDetail,
|
||||
useSelectorOptionMap,
|
||||
useSelectorOptions,
|
||||
} from '@/hooks/selectors/use-selector-query'
|
||||
|
||||
interface SelectorComboboxProps {
|
||||
blockId: string
|
||||
subBlock: SubBlockConfig
|
||||
selectorKey: SelectorKey
|
||||
selectorContext: SelectorContext
|
||||
disabled?: boolean
|
||||
isPreview?: boolean
|
||||
previewValue?: string | null
|
||||
placeholder?: string
|
||||
readOnly?: boolean
|
||||
onOptionChange?: (value: string) => void
|
||||
allowSearch?: boolean
|
||||
}
|
||||
|
||||
export function SelectorCombobox({
|
||||
blockId,
|
||||
subBlock,
|
||||
selectorKey,
|
||||
selectorContext,
|
||||
disabled,
|
||||
isPreview,
|
||||
previewValue,
|
||||
placeholder,
|
||||
readOnly,
|
||||
onOptionChange,
|
||||
allowSearch = true,
|
||||
}: SelectorComboboxProps) {
|
||||
const [storeValueRaw, setStoreValue] = useSubBlockValue<string | null | undefined>(
|
||||
blockId,
|
||||
subBlock.id
|
||||
)
|
||||
const storeValue = storeValueRaw ?? undefined
|
||||
const previewedValue = previewValue ?? undefined
|
||||
const activeValue: string | undefined = isPreview ? previewedValue : storeValue
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const {
|
||||
data: options = [],
|
||||
isLoading,
|
||||
error,
|
||||
} = useSelectorOptions(selectorKey, {
|
||||
context: selectorContext,
|
||||
search: allowSearch ? searchTerm : undefined,
|
||||
})
|
||||
const { data: detailOption } = useSelectorOptionDetail(selectorKey, {
|
||||
context: selectorContext,
|
||||
detailId: activeValue,
|
||||
})
|
||||
const optionMap = useSelectorOptionMap(options, detailOption ?? undefined)
|
||||
const selectedLabel = activeValue ? (optionMap.get(activeValue)?.label ?? activeValue) : ''
|
||||
const [inputValue, setInputValue] = useState(selectedLabel)
|
||||
const previousActiveValue = useRef<string | undefined>(activeValue)
|
||||
|
||||
useEffect(() => {
|
||||
if (previousActiveValue.current !== activeValue) {
|
||||
previousActiveValue.current = activeValue
|
||||
setIsEditing(false)
|
||||
}
|
||||
}, [activeValue])
|
||||
|
||||
useEffect(() => {
|
||||
if (!allowSearch) return
|
||||
if (!isEditing) {
|
||||
setInputValue(selectedLabel)
|
||||
}
|
||||
}, [selectedLabel, allowSearch, isEditing])
|
||||
|
||||
const comboboxOptions = useMemo(
|
||||
() =>
|
||||
Array.from(optionMap.values()).map((option) => ({
|
||||
label: option.label,
|
||||
value: option.id,
|
||||
})),
|
||||
[optionMap]
|
||||
)
|
||||
|
||||
const handleSelection = useCallback(
|
||||
(value: string) => {
|
||||
if (readOnly || disabled) return
|
||||
setStoreValue(value)
|
||||
setIsEditing(false)
|
||||
onOptionChange?.(value)
|
||||
},
|
||||
[setStoreValue, onOptionChange, readOnly, disabled]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='w-full'>
|
||||
<SubBlockInputController
|
||||
blockId={blockId}
|
||||
subBlockId={subBlock.id}
|
||||
config={subBlock}
|
||||
value={activeValue ?? ''}
|
||||
disabled={disabled || readOnly}
|
||||
isPreview={isPreview}
|
||||
>
|
||||
{({ ref, onDrop, onDragOver }) => (
|
||||
<EditableCombobox
|
||||
options={comboboxOptions}
|
||||
value={allowSearch ? inputValue : selectedLabel}
|
||||
selectedValue={activeValue ?? ''}
|
||||
onChange={(newValue) => {
|
||||
const matched = optionMap.get(newValue)
|
||||
if (matched) {
|
||||
setInputValue(matched.label)
|
||||
setIsEditing(false)
|
||||
handleSelection(matched.id)
|
||||
return
|
||||
}
|
||||
if (allowSearch) {
|
||||
setInputValue(newValue)
|
||||
setIsEditing(true)
|
||||
setSearchTerm(newValue)
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder || subBlock.placeholder || 'Select an option'}
|
||||
disabled={disabled || readOnly}
|
||||
editable={allowSearch}
|
||||
filterOptions={allowSearch}
|
||||
inputRef={ref as React.RefObject<HTMLInputElement>}
|
||||
inputProps={{
|
||||
onDrop: onDrop as (e: React.DragEvent<HTMLInputElement>) => void,
|
||||
onDragOver: onDragOver as (e: React.DragEvent<HTMLInputElement>) => void,
|
||||
}}
|
||||
isLoading={isLoading}
|
||||
error={error instanceof Error ? error.message : null}
|
||||
/>
|
||||
)}
|
||||
</SubBlockInputController>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,588 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { McpTransport } from '@/lib/mcp/types'
|
||||
import {
|
||||
checkEnvVarTrigger,
|
||||
EnvVarDropdown,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/env-var-dropdown'
|
||||
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/formatted-text'
|
||||
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
|
||||
import { useCreateMcpServer } from '@/hooks/queries/mcp'
|
||||
import { useMcpServerTest } from '@/hooks/use-mcp-server-test'
|
||||
|
||||
const logger = createLogger('McpServerModal')
|
||||
|
||||
interface McpServerModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onServerCreated?: () => void
|
||||
blockId: string
|
||||
}
|
||||
|
||||
interface McpServerFormData {
|
||||
name: string
|
||||
transport: McpTransport
|
||||
url?: string
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
|
||||
export function McpServerModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
onServerCreated,
|
||||
blockId,
|
||||
}: McpServerModalProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const [formData, setFormData] = useState<McpServerFormData>({
|
||||
name: '',
|
||||
transport: 'streamable-http',
|
||||
url: '',
|
||||
headers: { '': '' },
|
||||
})
|
||||
const createServerMutation = useCreateMcpServer()
|
||||
const [localError, setLocalError] = useState<string | null>(null)
|
||||
|
||||
// MCP server testing
|
||||
const { testResult, isTestingConnection, testConnection, clearTestResult } = useMcpServerTest()
|
||||
|
||||
// Environment variable dropdown state
|
||||
const [showEnvVars, setShowEnvVars] = useState(false)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [cursorPosition, setCursorPosition] = useState(0)
|
||||
const [activeInputField, setActiveInputField] = useState<
|
||||
'url' | 'header-key' | 'header-value' | null
|
||||
>(null)
|
||||
const [activeHeaderIndex, setActiveHeaderIndex] = useState<number | null>(null)
|
||||
const urlInputRef = useRef<HTMLInputElement>(null)
|
||||
const [urlScrollLeft, setUrlScrollLeft] = useState(0)
|
||||
const [headerScrollLeft, setHeaderScrollLeft] = useState<Record<string, number>>({})
|
||||
|
||||
const error = localError || createServerMutation.error?.message
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
name: '',
|
||||
transport: 'streamable-http',
|
||||
url: '',
|
||||
headers: { '': '' },
|
||||
})
|
||||
setLocalError(null)
|
||||
createServerMutation.reset()
|
||||
setShowEnvVars(false)
|
||||
setActiveInputField(null)
|
||||
setActiveHeaderIndex(null)
|
||||
clearTestResult()
|
||||
}
|
||||
|
||||
// Handle environment variable selection
|
||||
const handleEnvVarSelect = useCallback(
|
||||
(newValue: string) => {
|
||||
if (activeInputField === 'url') {
|
||||
setFormData((prev) => ({ ...prev, url: newValue }))
|
||||
} else if (activeInputField === 'header-key' && activeHeaderIndex !== null) {
|
||||
const headerEntries = Object.entries(formData.headers || {})
|
||||
const [oldKey, value] = headerEntries[activeHeaderIndex]
|
||||
const newHeaders = { ...formData.headers }
|
||||
delete newHeaders[oldKey]
|
||||
newHeaders[newValue.replace(/[{}]/g, '')] = value
|
||||
setFormData((prev) => ({ ...prev, headers: newHeaders }))
|
||||
} else if (activeInputField === 'header-value' && activeHeaderIndex !== null) {
|
||||
const headerEntries = Object.entries(formData.headers || {})
|
||||
const [key] = headerEntries[activeHeaderIndex]
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
headers: { ...prev.headers, [key]: newValue },
|
||||
}))
|
||||
}
|
||||
setShowEnvVars(false)
|
||||
setActiveInputField(null)
|
||||
setActiveHeaderIndex(null)
|
||||
},
|
||||
[activeInputField, activeHeaderIndex, formData.headers]
|
||||
)
|
||||
|
||||
// Handle input change with env var detection
|
||||
const handleInputChange = useCallback(
|
||||
(field: 'url' | 'header-key' | 'header-value', value: string, headerIndex?: number) => {
|
||||
const input = document.activeElement as HTMLInputElement
|
||||
const pos = input?.selectionStart || 0
|
||||
|
||||
setCursorPosition(pos)
|
||||
|
||||
// Clear test result when any field changes
|
||||
if (testResult) {
|
||||
clearTestResult()
|
||||
}
|
||||
|
||||
// Check if we should show the environment variables dropdown
|
||||
const envVarTrigger = checkEnvVarTrigger(value, pos)
|
||||
setShowEnvVars(envVarTrigger.show)
|
||||
setSearchTerm(envVarTrigger.show ? envVarTrigger.searchTerm : '')
|
||||
|
||||
if (envVarTrigger.show) {
|
||||
setActiveInputField(field)
|
||||
setActiveHeaderIndex(headerIndex ?? null)
|
||||
} else {
|
||||
setActiveInputField(null)
|
||||
setActiveHeaderIndex(null)
|
||||
}
|
||||
|
||||
// Update form data
|
||||
if (field === 'url') {
|
||||
setFormData((prev) => ({ ...prev, url: value }))
|
||||
} else if (field === 'header-key' && headerIndex !== undefined) {
|
||||
const headerEntries = Object.entries(formData.headers || {})
|
||||
const [oldKey, headerValue] = headerEntries[headerIndex]
|
||||
const newHeaders = { ...formData.headers }
|
||||
delete newHeaders[oldKey]
|
||||
newHeaders[value] = headerValue
|
||||
|
||||
// Add a new empty header row if this is the last row and both key and value have content
|
||||
const isLastRow = headerIndex === headerEntries.length - 1
|
||||
const hasContent = value.trim() !== '' && headerValue.trim() !== ''
|
||||
if (isLastRow && hasContent) {
|
||||
newHeaders[''] = ''
|
||||
}
|
||||
|
||||
setFormData((prev) => ({ ...prev, headers: newHeaders }))
|
||||
} else if (field === 'header-value' && headerIndex !== undefined) {
|
||||
const headerEntries = Object.entries(formData.headers || {})
|
||||
const [key] = headerEntries[headerIndex]
|
||||
const newHeaders = { ...formData.headers, [key]: value }
|
||||
|
||||
// Add a new empty header row if this is the last row and both key and value have content
|
||||
const isLastRow = headerIndex === headerEntries.length - 1
|
||||
const hasContent = key.trim() !== '' && value.trim() !== ''
|
||||
if (isLastRow && hasContent) {
|
||||
newHeaders[''] = ''
|
||||
}
|
||||
|
||||
setFormData((prev) => ({ ...prev, headers: newHeaders }))
|
||||
}
|
||||
},
|
||||
[formData.headers, testResult, clearTestResult]
|
||||
)
|
||||
|
||||
const handleTestConnection = useCallback(async () => {
|
||||
if (!formData.name.trim() || !formData.url?.trim()) return
|
||||
|
||||
await testConnection({
|
||||
name: formData.name,
|
||||
transport: formData.transport,
|
||||
url: formData.url,
|
||||
headers: formData.headers,
|
||||
timeout: 30000,
|
||||
workspaceId,
|
||||
})
|
||||
}, [formData, testConnection, workspaceId])
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!formData.name.trim()) {
|
||||
setLocalError('Server name is required')
|
||||
return
|
||||
}
|
||||
|
||||
if (!formData.url?.trim()) {
|
||||
setLocalError('Server URL is required for HTTP/SSE transport')
|
||||
return
|
||||
}
|
||||
|
||||
setLocalError(null)
|
||||
createServerMutation.reset()
|
||||
|
||||
try {
|
||||
// If no test has been done, test first
|
||||
if (!testResult) {
|
||||
const result = await testConnection({
|
||||
name: formData.name,
|
||||
transport: formData.transport,
|
||||
url: formData.url,
|
||||
headers: formData.headers,
|
||||
timeout: 30000,
|
||||
workspaceId,
|
||||
})
|
||||
|
||||
// If test fails, don't proceed
|
||||
if (!result.success) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// If we have a failed test result, don't proceed
|
||||
if (testResult && !testResult.success) {
|
||||
return
|
||||
}
|
||||
|
||||
// Filter out empty headers
|
||||
const cleanHeaders = Object.fromEntries(
|
||||
Object.entries(formData.headers || {}).filter(
|
||||
([key, value]) => key.trim() !== '' && value.trim() !== ''
|
||||
)
|
||||
)
|
||||
|
||||
await createServerMutation.mutateAsync({
|
||||
workspaceId,
|
||||
config: {
|
||||
name: formData.name.trim(),
|
||||
transport: formData.transport,
|
||||
url: formData.url,
|
||||
timeout: 30000,
|
||||
headers: cleanHeaders,
|
||||
enabled: true,
|
||||
},
|
||||
})
|
||||
|
||||
logger.info(`Added MCP server: ${formData.name}`)
|
||||
|
||||
// Close modal and reset form immediately after successful creation
|
||||
resetForm()
|
||||
onOpenChange(false)
|
||||
onServerCreated?.()
|
||||
} catch (error) {
|
||||
logger.error('Failed to add MCP server:', error)
|
||||
setLocalError(error instanceof Error ? error.message : 'Failed to add MCP server')
|
||||
}
|
||||
}, [
|
||||
formData,
|
||||
testResult,
|
||||
testConnection,
|
||||
onOpenChange,
|
||||
onServerCreated,
|
||||
createServerMutation,
|
||||
workspaceId,
|
||||
])
|
||||
|
||||
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='sm:max-w-[600px]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add MCP Server</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure a new Model Context Protocol server to extend your workflow capabilities.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='space-y-4 py-4'>
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<div>
|
||||
<Label htmlFor='server-name'>Server Name</Label>
|
||||
<Input
|
||||
id='server-name'
|
||||
placeholder='e.g., My MCP Server'
|
||||
value={formData.name}
|
||||
onChange={(e) => {
|
||||
if (testResult) clearTestResult()
|
||||
setFormData((prev) => ({ ...prev, name: e.target.value }))
|
||||
}}
|
||||
className='h-9'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor='transport'>Transport Type</Label>
|
||||
<Select
|
||||
value={formData.transport}
|
||||
onValueChange={(value: 'http' | 'sse' | 'streamable-http') => {
|
||||
if (testResult) clearTestResult()
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
transport: value as McpTransport,
|
||||
}))
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className='h-9'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='streamable-http'>Streamable HTTP</SelectItem>
|
||||
<SelectItem value='http'>HTTP</SelectItem>
|
||||
<SelectItem value='sse'>Server-Sent Events</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='relative'>
|
||||
<Label htmlFor='server-url'>Server URL</Label>
|
||||
<div className='relative'>
|
||||
<Input
|
||||
ref={urlInputRef}
|
||||
id='server-url'
|
||||
placeholder='https://mcp.server.dev/{{YOUR_API_KEY}}/sse'
|
||||
value={formData.url}
|
||||
onChange={(e) => handleInputChange('url', e.target.value)}
|
||||
onScroll={(e) => {
|
||||
const scrollLeft = e.currentTarget.scrollLeft
|
||||
setUrlScrollLeft(scrollLeft)
|
||||
}}
|
||||
onInput={(e) => {
|
||||
const scrollLeft = e.currentTarget.scrollLeft
|
||||
setUrlScrollLeft(scrollLeft)
|
||||
}}
|
||||
className='h-9 text-transparent caret-foreground placeholder:text-muted-foreground/50'
|
||||
/>
|
||||
|
||||
{/* Overlay for styled text display */}
|
||||
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden px-3 text-sm'>
|
||||
<div
|
||||
className='whitespace-nowrap'
|
||||
style={{ transform: `translateX(-${urlScrollLeft}px)` }}
|
||||
>
|
||||
{formatDisplayText(formData.url || '', {
|
||||
accessiblePrefixes,
|
||||
highlightAll: !accessiblePrefixes,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Environment Variables Dropdown */}
|
||||
{showEnvVars && activeInputField === 'url' && (
|
||||
<EnvVarDropdown
|
||||
visible={showEnvVars}
|
||||
onSelect={handleEnvVarSelect}
|
||||
searchTerm={searchTerm}
|
||||
inputValue={formData.url || ''}
|
||||
cursorPosition={cursorPosition}
|
||||
workspaceId={workspaceId}
|
||||
onClose={() => {
|
||||
setShowEnvVars(false)
|
||||
setActiveInputField(null)
|
||||
}}
|
||||
className='w-full'
|
||||
maxHeight='250px'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Headers (Optional)</Label>
|
||||
<div className='space-y-2'>
|
||||
{Object.entries(formData.headers || {}).map(([key, value], index) => (
|
||||
<div key={index} className='relative flex gap-2'>
|
||||
{/* Header Name Input */}
|
||||
<div className='relative flex-1'>
|
||||
<Input
|
||||
placeholder='Name'
|
||||
value={key}
|
||||
onChange={(e) => handleInputChange('header-key', e.target.value, index)}
|
||||
onScroll={(e) => {
|
||||
const scrollLeft = e.currentTarget.scrollLeft
|
||||
setHeaderScrollLeft((prev) => ({ ...prev, [`key-${index}`]: scrollLeft }))
|
||||
}}
|
||||
onInput={(e) => {
|
||||
const scrollLeft = e.currentTarget.scrollLeft
|
||||
setHeaderScrollLeft((prev) => ({ ...prev, [`key-${index}`]: scrollLeft }))
|
||||
}}
|
||||
className='h-9 text-transparent caret-foreground placeholder:text-muted-foreground/50'
|
||||
/>
|
||||
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden px-3 text-sm'>
|
||||
<div
|
||||
className='whitespace-nowrap'
|
||||
style={{
|
||||
transform: `translateX(-${headerScrollLeft[`key-${index}`] || 0}px)`,
|
||||
}}
|
||||
>
|
||||
{formatDisplayText(key || '', {
|
||||
accessiblePrefixes,
|
||||
highlightAll: !accessiblePrefixes,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Header Value Input */}
|
||||
<div className='relative flex-1'>
|
||||
<Input
|
||||
placeholder='Value'
|
||||
value={value}
|
||||
onChange={(e) => handleInputChange('header-value', e.target.value, index)}
|
||||
onScroll={(e) => {
|
||||
const scrollLeft = e.currentTarget.scrollLeft
|
||||
setHeaderScrollLeft((prev) => ({ ...prev, [`value-${index}`]: scrollLeft }))
|
||||
}}
|
||||
onInput={(e) => {
|
||||
const scrollLeft = e.currentTarget.scrollLeft
|
||||
setHeaderScrollLeft((prev) => ({ ...prev, [`value-${index}`]: scrollLeft }))
|
||||
}}
|
||||
className='h-9 text-transparent caret-foreground placeholder:text-muted-foreground/50'
|
||||
/>
|
||||
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden px-3 text-sm'>
|
||||
<div
|
||||
className='whitespace-nowrap'
|
||||
style={{
|
||||
transform: `translateX(-${headerScrollLeft[`value-${index}`] || 0}px)`,
|
||||
}}
|
||||
>
|
||||
{formatDisplayText(value || '', {
|
||||
accessiblePrefixes,
|
||||
highlightAll: !accessiblePrefixes,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
onClick={() => {
|
||||
const headerEntries = Object.entries(formData.headers || {})
|
||||
if (headerEntries.length === 1) {
|
||||
// If this is the only header, just clear it instead of deleting
|
||||
setFormData((prev) => ({ ...prev, headers: { '': '' } }))
|
||||
} else {
|
||||
// Delete this header
|
||||
const newHeaders = { ...formData.headers }
|
||||
delete newHeaders[key]
|
||||
setFormData((prev) => ({ ...prev, headers: newHeaders }))
|
||||
}
|
||||
}}
|
||||
className='h-9 w-9 p-0 text-muted-foreground hover:text-foreground'
|
||||
>
|
||||
<X className='h-3 w-3' />
|
||||
</Button>
|
||||
|
||||
{/* Environment Variables Dropdown for Header Key */}
|
||||
{showEnvVars &&
|
||||
activeInputField === 'header-key' &&
|
||||
activeHeaderIndex === index && (
|
||||
<EnvVarDropdown
|
||||
visible={showEnvVars}
|
||||
onSelect={handleEnvVarSelect}
|
||||
searchTerm={searchTerm}
|
||||
inputValue={key}
|
||||
cursorPosition={cursorPosition}
|
||||
workspaceId={workspaceId}
|
||||
onClose={() => {
|
||||
setShowEnvVars(false)
|
||||
setActiveInputField(null)
|
||||
setActiveHeaderIndex(null)
|
||||
}}
|
||||
className='w-full'
|
||||
maxHeight='150px'
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
zIndex: 9999,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Environment Variables Dropdown for Header Value */}
|
||||
{showEnvVars &&
|
||||
activeInputField === 'header-value' &&
|
||||
activeHeaderIndex === index && (
|
||||
<EnvVarDropdown
|
||||
visible={showEnvVars}
|
||||
onSelect={handleEnvVarSelect}
|
||||
searchTerm={searchTerm}
|
||||
inputValue={value}
|
||||
cursorPosition={cursorPosition}
|
||||
workspaceId={workspaceId}
|
||||
onClose={() => {
|
||||
setShowEnvVars(false)
|
||||
setActiveInputField(null)
|
||||
setActiveHeaderIndex(null)
|
||||
}}
|
||||
className='w-full'
|
||||
maxHeight='250px'
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
right: 0,
|
||||
zIndex: 9999,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className='rounded-md bg-destructive/10 px-3 py-2 text-destructive text-sm'>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Test Connection and Actions */}
|
||||
<div className='border-t pt-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={handleTestConnection}
|
||||
disabled={isTestingConnection || !formData.name.trim() || !formData.url?.trim()}
|
||||
className='text-muted-foreground hover:text-foreground'
|
||||
>
|
||||
{isTestingConnection ? 'Testing...' : 'Test Connection'}
|
||||
</Button>
|
||||
{testResult?.success && (
|
||||
<span className='text-green-600 text-xs'>✓ Connected</span>
|
||||
)}
|
||||
</div>
|
||||
{testResult && !testResult.success && (
|
||||
<div className='rounded border border-red-200 bg-red-50 px-2 py-1.5 text-red-600 text-xs dark:border-red-800 dark:bg-red-950/20'>
|
||||
<div className='font-medium'>Connection failed</div>
|
||||
<div className='text-red-500 dark:text-red-400'>
|
||||
{testResult.error || testResult.message}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
resetForm()
|
||||
onOpenChange(false)
|
||||
}}
|
||||
disabled={createServerMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
onClick={handleSubmit}
|
||||
disabled={
|
||||
createServerMutation.isPending || !formData.name.trim() || !formData.url?.trim()
|
||||
}
|
||||
>
|
||||
{createServerMutation.isPending ? 'Adding...' : 'Add Server'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Check, ChevronDown, ExternalLink, Plus, RefreshCw } from 'lucide-react'
|
||||
import { Button } from '@/components/emcn/components/button/button'
|
||||
import {
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
type Credential,
|
||||
getCanonicalScopesForProvider,
|
||||
getProviderIdFromServiceId,
|
||||
OAUTH_PROVIDERS,
|
||||
@@ -20,8 +19,8 @@ import {
|
||||
parseProvider,
|
||||
} from '@/lib/oauth'
|
||||
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
|
||||
import { useOAuthCredentialDetail, useOAuthCredentials } from '@/hooks/queries/oauth-credentials'
|
||||
import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status'
|
||||
import { useDisplayNamesStore } from '@/stores/display-names/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
const logger = createLogger('ToolCredentialSelector')
|
||||
@@ -70,8 +69,6 @@ export function ToolCredentialSelector({
|
||||
disabled = false,
|
||||
}: ToolCredentialSelectorProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [credentials, setCredentials] = useState<Credential[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [showOAuthModal, setShowOAuthModal] = useState(false)
|
||||
const [selectedId, setSelectedId] = useState('')
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
@@ -80,80 +77,43 @@ export function ToolCredentialSelector({
|
||||
setSelectedId(value)
|
||||
}, [value])
|
||||
|
||||
const fetchCredentials = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const response = await fetch(`/api/auth/oauth/credentials?provider=${provider}`)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setCredentials(data.credentials || [])
|
||||
const {
|
||||
data: fetchedCredentials = [],
|
||||
isFetching: credentialsLoading,
|
||||
refetch: refetchCredentials,
|
||||
} = useOAuthCredentials(provider, true)
|
||||
|
||||
// Cache credential names for block previews
|
||||
if (provider) {
|
||||
const credentialMap = (data.credentials || []).reduce(
|
||||
(acc: Record<string, string>, cred: Credential) => {
|
||||
acc[cred.id] = cred.name
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
useDisplayNamesStore.getState().setDisplayNames('credentials', provider, credentialMap)
|
||||
}
|
||||
const shouldFetchDetail =
|
||||
Boolean(value) &&
|
||||
!fetchedCredentials.some((cred) => cred.id === value) &&
|
||||
Boolean(activeWorkflowId)
|
||||
|
||||
if (
|
||||
value &&
|
||||
!(data.credentials || []).some((cred: Credential) => cred.id === value) &&
|
||||
activeWorkflowId
|
||||
) {
|
||||
try {
|
||||
const metaResp = await fetch(
|
||||
`/api/auth/oauth/credentials?credentialId=${value}&workflowId=${activeWorkflowId}`
|
||||
)
|
||||
if (metaResp.ok) {
|
||||
const meta = await metaResp.json()
|
||||
if (meta.credentials?.length) {
|
||||
const combinedCredentials = [meta.credentials[0], ...(data.credentials || [])]
|
||||
setCredentials(combinedCredentials)
|
||||
const { data: collaboratorCredentials = [], isFetching: collaboratorLoading } =
|
||||
useOAuthCredentialDetail(
|
||||
shouldFetchDetail ? value : undefined,
|
||||
activeWorkflowId || undefined,
|
||||
shouldFetchDetail
|
||||
)
|
||||
|
||||
const credentialMap = combinedCredentials.reduce(
|
||||
(acc: Record<string, string>, cred: Credential) => {
|
||||
acc[cred.id] = cred.name
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
useDisplayNamesStore
|
||||
.getState()
|
||||
.setDisplayNames('credentials', provider, credentialMap)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.error('Error fetching credentials:', { error: await response.text() })
|
||||
setCredentials([])
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching credentials:', { error })
|
||||
setCredentials([])
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
const credentials = useMemo(() => {
|
||||
if (collaboratorCredentials.length === 0) {
|
||||
return fetchedCredentials
|
||||
}
|
||||
}, [provider, value, onChange])
|
||||
|
||||
// Fetch credentials on initial mount only
|
||||
useEffect(() => {
|
||||
fetchCredentials()
|
||||
// This effect should only run once on mount
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
const collaborator = collaboratorCredentials[0]
|
||||
if (!collaborator) {
|
||||
return fetchedCredentials
|
||||
}
|
||||
const alreadyIncluded = fetchedCredentials.some((cred) => cred.id === collaborator.id)
|
||||
if (alreadyIncluded) {
|
||||
return fetchedCredentials
|
||||
}
|
||||
return [collaborator, ...fetchedCredentials]
|
||||
}, [fetchedCredentials, collaboratorCredentials])
|
||||
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
fetchCredentials()
|
||||
void refetchCredentials()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,7 +122,7 @@ export function ToolCredentialSelector({
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
}
|
||||
}, [fetchCredentials])
|
||||
}, [refetchCredentials])
|
||||
|
||||
const handleSelect = (credentialId: string) => {
|
||||
setSelectedId(credentialId)
|
||||
@@ -172,13 +132,13 @@ export function ToolCredentialSelector({
|
||||
|
||||
const handleOAuthClose = () => {
|
||||
setShowOAuthModal(false)
|
||||
fetchCredentials()
|
||||
void refetchCredentials()
|
||||
}
|
||||
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
setOpen(isOpen)
|
||||
if (isOpen) {
|
||||
fetchCredentials()
|
||||
void refetchCredentials()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,7 +150,8 @@ export function ToolCredentialSelector({
|
||||
const missingRequiredScopes = hasSelection
|
||||
? getMissingRequiredScopes(selectedCredential, requiredScopes || [])
|
||||
: []
|
||||
const needsUpdate = hasSelection && missingRequiredScopes.length > 0 && !disabled && !isLoading
|
||||
const needsUpdate =
|
||||
hasSelection && missingRequiredScopes.length > 0 && !disabled && !credentialsLoading
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -224,7 +185,7 @@ export function ToolCredentialSelector({
|
||||
<Command>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{isLoading ? (
|
||||
{credentialsLoading || collaboratorLoading ? (
|
||||
<div className='flex items-center justify-center p-4'>
|
||||
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||
<span className='ml-2'>Loading...</span>
|
||||
|
||||
@@ -1642,19 +1642,19 @@ export function ToolInput({
|
||||
<p className='text-xs'>
|
||||
{tool.usageControl === 'auto' && (
|
||||
<span>
|
||||
<span className='font-medium'>Auto:</span> The model decides when to
|
||||
use the tool
|
||||
<span className='font-medium' /> The model decides when to use the
|
||||
tool
|
||||
</span>
|
||||
)}
|
||||
{tool.usageControl === 'force' && (
|
||||
<span>
|
||||
<span className='font-medium'>Force:</span> Always use this tool in
|
||||
the response
|
||||
<span className='font-medium' /> Always use this tool in the
|
||||
response
|
||||
</span>
|
||||
)}
|
||||
{tool.usageControl === 'none' && (
|
||||
<span>
|
||||
<span className='font-medium'>Deny:</span> Never use this tool
|
||||
<span className='font-medium' /> Never use this tool
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Badge, Button, Combobox, Input } from '@/components/emcn'
|
||||
import { Trash } from '@/components/emcn/icons/trash'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/formatted-text'
|
||||
@@ -68,6 +60,7 @@ export function VariablesInput({
|
||||
const valueInputRefs = useRef<Record<string, HTMLInputElement | HTMLTextAreaElement>>({})
|
||||
const overlayRefs = useRef<Record<string, HTMLDivElement>>({})
|
||||
const [dragHighlight, setDragHighlight] = useState<Record<string, boolean>>({})
|
||||
const [collapsedAssignments, setCollapsedAssignments] = useState<Record<string, boolean>>({})
|
||||
|
||||
const currentWorkflowVariables = Object.values(workflowVariables).filter(
|
||||
(v: Variable) => v.workflowId === workflowId
|
||||
@@ -75,6 +68,7 @@ export function VariablesInput({
|
||||
|
||||
const value = isPreview ? previewValue : storeValue
|
||||
const assignments: VariableAssignment[] = value || []
|
||||
const isReadOnly = isPreview || disabled
|
||||
|
||||
const getAvailableVariablesFor = (currentAssignmentId: string) => {
|
||||
const otherSelectedIds = new Set(
|
||||
@@ -91,8 +85,41 @@ export function VariablesInput({
|
||||
const allVariablesAssigned =
|
||||
!hasNoWorkflowVariables && getAvailableVariablesFor('new').length === 0
|
||||
|
||||
// Initialize with one empty assignment if none exist and not in preview/disabled mode
|
||||
// Also add assignment when first variable is created
|
||||
useEffect(() => {
|
||||
if (!isReadOnly && assignments.length === 0 && currentWorkflowVariables.length > 0) {
|
||||
const initialAssignment: VariableAssignment = {
|
||||
...DEFAULT_ASSIGNMENT,
|
||||
id: crypto.randomUUID(),
|
||||
}
|
||||
setStoreValue([initialAssignment])
|
||||
}
|
||||
}, [currentWorkflowVariables.length, isReadOnly, assignments.length, setStoreValue])
|
||||
|
||||
// Clean up assignments when their associated variables are deleted
|
||||
useEffect(() => {
|
||||
if (isReadOnly || assignments.length === 0) return
|
||||
|
||||
const currentVariableIds = new Set(currentWorkflowVariables.map((v) => v.id))
|
||||
const validAssignments = assignments.filter((assignment) => {
|
||||
// Keep assignments that haven't selected a variable yet
|
||||
if (!assignment.variableId) return true
|
||||
// Keep assignments whose variable still exists
|
||||
return currentVariableIds.has(assignment.variableId)
|
||||
})
|
||||
|
||||
// If all variables were deleted, clear all assignments
|
||||
if (currentWorkflowVariables.length === 0) {
|
||||
setStoreValue([])
|
||||
} else if (validAssignments.length !== assignments.length) {
|
||||
// Some assignments reference deleted variables, remove them
|
||||
setStoreValue(validAssignments.length > 0 ? validAssignments : [])
|
||||
}
|
||||
}, [currentWorkflowVariables, assignments, isReadOnly, setStoreValue])
|
||||
|
||||
const addAssignment = () => {
|
||||
if (isPreview || disabled) return
|
||||
if (isPreview || disabled || allVariablesAssigned) return
|
||||
|
||||
const newAssignment: VariableAssignment = {
|
||||
...DEFAULT_ASSIGNMENT,
|
||||
@@ -219,6 +246,13 @@ export function VariablesInput({
|
||||
setDragHighlight((prev) => ({ ...prev, [assignmentId]: false }))
|
||||
}
|
||||
|
||||
const toggleCollapse = (assignmentId: string) => {
|
||||
setCollapsedAssignments((prev) => ({
|
||||
...prev,
|
||||
[assignmentId]: !prev[assignmentId],
|
||||
}))
|
||||
}
|
||||
|
||||
if (isPreview && (!assignments || assignments.length === 0)) {
|
||||
return (
|
||||
<div className='flex flex-col items-center justify-center rounded-md border border-border/40 border-dashed bg-muted/20 py-8 text-center'>
|
||||
@@ -244,225 +278,195 @@ export function VariablesInput({
|
||||
}
|
||||
|
||||
if (!isPreview && hasNoWorkflowVariables && assignments.length === 0) {
|
||||
return (
|
||||
<div className='flex flex-col items-center justify-center rounded-lg border border-border/50 bg-muted/30 p-8 text-center'>
|
||||
<svg
|
||||
className='mb-3 h-10 w-10 text-muted-foreground/60'
|
||||
fill='none'
|
||||
viewBox='0 0 24 24'
|
||||
stroke='currentColor'
|
||||
>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth={1.5}
|
||||
d='M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z'
|
||||
/>
|
||||
</svg>
|
||||
<p className='font-medium text-muted-foreground text-sm'>No variables found</p>
|
||||
<p className='mt-1 text-muted-foreground/80 text-xs'>
|
||||
Add variables in the Variables panel to get started
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
return <p className='text-[var(--text-muted)] text-sm'>No variables available</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-2'>
|
||||
{assignments && assignments.length > 0 ? (
|
||||
<div className='space-y-2'>
|
||||
{assignments.map((assignment) => {
|
||||
<div className='space-y-[8px]'>
|
||||
{assignments && assignments.length > 0 && (
|
||||
<div className='space-y-[8px]'>
|
||||
{assignments.map((assignment, index) => {
|
||||
const collapsed = collapsedAssignments[assignment.id] || false
|
||||
const availableVars = getAvailableVariablesFor(assignment.id)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={assignment.id}
|
||||
className='group relative rounded-lg border border-border/50 bg-background/50 p-3 transition-all hover:border-border hover:bg-background'
|
||||
>
|
||||
{!isPreview && !disabled && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='absolute top-2 right-2 h-6 w-6 opacity-0 transition-opacity group-hover:opacity-100'
|
||||
onClick={() => removeAssignment(assignment.id)}
|
||||
>
|
||||
<Trash className='h-3.5 w-3.5 text-muted-foreground hover:text-destructive' />
|
||||
</Button>
|
||||
data-assignment-id={assignment.id}
|
||||
className={cn(
|
||||
'rounded-[4px] border border-[var(--border-strong)] bg-[#1F1F1F]',
|
||||
collapsed ? 'overflow-hidden' : 'overflow-visible'
|
||||
)}
|
||||
|
||||
<div className='space-y-3'>
|
||||
<div className='space-y-1.5'>
|
||||
<div className='flex items-center justify-between pr-8'>
|
||||
<Label className='text-xs'>Variable</Label>
|
||||
{assignment.variableName && (
|
||||
<span className='rounded-md bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground'>
|
||||
{assignment.type}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Select
|
||||
value={assignment.variableId || assignment.variableName || ''}
|
||||
onValueChange={(value) => {
|
||||
if (value === '__new__') {
|
||||
return
|
||||
}
|
||||
handleVariableSelect(assignment.id, value)
|
||||
}}
|
||||
disabled={isPreview || disabled}
|
||||
>
|
||||
<SelectTrigger className='h-9 border border-input bg-white dark:border-input/60 dark:bg-background'>
|
||||
<SelectValue placeholder='Select a variable...' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(() => {
|
||||
const availableVars = getAvailableVariablesFor(assignment.id)
|
||||
return availableVars.length > 0 ? (
|
||||
availableVars.map((variable) => (
|
||||
<SelectItem key={variable.id} value={variable.id}>
|
||||
{variable.name}
|
||||
</SelectItem>
|
||||
))
|
||||
) : (
|
||||
<div className='p-2 text-center text-muted-foreground text-sm'>
|
||||
{currentWorkflowVariables.length > 0
|
||||
? 'All variables have been assigned.'
|
||||
: 'No variables defined in this workflow.'}
|
||||
{currentWorkflowVariables.length === 0 && (
|
||||
<>
|
||||
<br />
|
||||
Add them in the Variables panel.
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
>
|
||||
<div
|
||||
className='flex cursor-pointer items-center justify-between bg-transparent px-[10px] py-[5px]'
|
||||
onClick={() => toggleCollapse(assignment.id)}
|
||||
>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
|
||||
<span className='block truncate font-medium text-[14px] text-[var(--text-tertiary)]'>
|
||||
{assignment.variableName || `Variable ${index + 1}`}
|
||||
</span>
|
||||
{assignment.variableName && (
|
||||
<Badge className='h-[20px] text-[13px]'>{assignment.type}</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='space-y-1.5'>
|
||||
<Label className='text-xs'>Value</Label>
|
||||
{assignment.type === 'object' || assignment.type === 'array' ? (
|
||||
<div className='relative'>
|
||||
<Textarea
|
||||
ref={(el) => {
|
||||
if (el) valueInputRefs.current[assignment.id] = el
|
||||
}}
|
||||
value={assignment.value || ''}
|
||||
onChange={(e) =>
|
||||
handleValueInputChange(
|
||||
assignment.id,
|
||||
e.target.value,
|
||||
e.target.selectionStart ?? undefined
|
||||
)
|
||||
}
|
||||
placeholder={
|
||||
assignment.type === 'object'
|
||||
? '{\n "key": "value"\n}'
|
||||
: '[\n 1, 2, 3\n]'
|
||||
}
|
||||
disabled={isPreview || disabled}
|
||||
className={cn(
|
||||
'min-h-[120px] border border-input bg-white font-mono text-sm text-transparent caret-foreground placeholder:text-muted-foreground/50 dark:border-input/60 dark:bg-background',
|
||||
dragHighlight[assignment.id] && 'ring-2 ring-blue-500 ring-offset-2'
|
||||
)}
|
||||
style={{
|
||||
fontFamily: 'inherit',
|
||||
lineHeight: 'inherit',
|
||||
wordBreak: 'break-word',
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
onDrop={(e) => handleDrop(e, assignment.id)}
|
||||
onDragOver={(e) => handleDragOver(e, assignment.id)}
|
||||
onDragLeave={(e) => handleDragLeave(e, assignment.id)}
|
||||
/>
|
||||
<div
|
||||
ref={(el) => {
|
||||
if (el) overlayRefs.current[assignment.id] = el
|
||||
}}
|
||||
className='pointer-events-none absolute inset-0 flex items-start overflow-auto bg-transparent px-3 py-2 font-mono text-sm'
|
||||
style={{
|
||||
fontFamily: 'inherit',
|
||||
lineHeight: 'inherit',
|
||||
}}
|
||||
>
|
||||
<div className='w-full whitespace-pre-wrap break-words'>
|
||||
{formatDisplayText(assignment.value || '', {
|
||||
accessiblePrefixes,
|
||||
highlightAll: !accessiblePrefixes,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='relative'>
|
||||
<Input
|
||||
ref={(el) => {
|
||||
if (el) valueInputRefs.current[assignment.id] = el
|
||||
}}
|
||||
value={assignment.value || ''}
|
||||
onChange={(e) =>
|
||||
handleValueInputChange(
|
||||
assignment.id,
|
||||
e.target.value,
|
||||
e.target.selectionStart ?? undefined
|
||||
)
|
||||
}
|
||||
placeholder={`${assignment.type} value`}
|
||||
disabled={isPreview || disabled}
|
||||
autoComplete='off'
|
||||
className={cn(
|
||||
'h-9 border border-input bg-white text-transparent caret-foreground placeholder:text-muted-foreground/50 dark:border-input/60 dark:bg-background',
|
||||
dragHighlight[assignment.id] && 'ring-2 ring-blue-500 ring-offset-2'
|
||||
)}
|
||||
onDrop={(e) => handleDrop(e, assignment.id)}
|
||||
onDragOver={(e) => handleDragOver(e, assignment.id)}
|
||||
onDragLeave={(e) => handleDragLeave(e, assignment.id)}
|
||||
/>
|
||||
<div
|
||||
ref={(el) => {
|
||||
if (el) overlayRefs.current[assignment.id] = el
|
||||
}}
|
||||
className='pointer-events-none absolute inset-0 flex items-center overflow-hidden bg-transparent px-3 text-sm'
|
||||
>
|
||||
<div className='w-full whitespace-nowrap'>
|
||||
{formatDisplayText(assignment.value || '', {
|
||||
accessiblePrefixes,
|
||||
highlightAll: !accessiblePrefixes,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showTags && activeFieldId === assignment.id && (
|
||||
<TagDropdown
|
||||
visible={showTags}
|
||||
onSelect={handleTagSelect}
|
||||
blockId={blockId}
|
||||
activeSourceBlockId={activeSourceBlockId}
|
||||
inputValue={assignment.value || ''}
|
||||
cursorPosition={cursorPosition}
|
||||
onClose={() => setShowTags(false)}
|
||||
className='absolute top-full left-0 z-50 mt-1'
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className='flex items-center gap-[8px] pl-[8px]'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={addAssignment}
|
||||
disabled={isPreview || disabled || allVariablesAssigned}
|
||||
className='h-auto p-0'
|
||||
>
|
||||
<Plus className='h-[14px] w-[14px]' />
|
||||
<span className='sr-only'>Add Variable</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => removeAssignment(assignment.id)}
|
||||
disabled={isPreview || disabled || assignments.length === 1}
|
||||
className='h-auto p-0 text-[var(--text-error)] hover:text-[var(--text-error)]'
|
||||
>
|
||||
<Trash className='h-[14px] w-[14px]' />
|
||||
<span className='sr-only'>Delete Variable</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!collapsed && (
|
||||
<div className='flex flex-col gap-[6px] border-[var(--border-strong)] border-t px-[10px] pt-[6px] pb-[10px]'>
|
||||
<div className='flex flex-col gap-[4px]'>
|
||||
<Label className='text-[13px]'>Variable</Label>
|
||||
<Combobox
|
||||
options={availableVars.map((v) => ({ label: v.name, value: v.id }))}
|
||||
value={assignment.variableId || assignment.variableName || ''}
|
||||
onChange={(value) => handleVariableSelect(assignment.id, value)}
|
||||
placeholder='Select a variable...'
|
||||
disabled={isPreview || disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-[4px]'>
|
||||
<Label className='text-[13px]'>Value</Label>
|
||||
{assignment.type === 'object' || assignment.type === 'array' ? (
|
||||
<div className='relative'>
|
||||
<Textarea
|
||||
ref={(el) => {
|
||||
if (el) valueInputRefs.current[assignment.id] = el
|
||||
}}
|
||||
value={assignment.value || ''}
|
||||
onChange={(e) =>
|
||||
handleValueInputChange(
|
||||
assignment.id,
|
||||
e.target.value,
|
||||
e.target.selectionStart ?? undefined
|
||||
)
|
||||
}
|
||||
placeholder={
|
||||
assignment.type === 'object'
|
||||
? '{\n "key": "value"\n}'
|
||||
: '[\n 1, 2, 3\n]'
|
||||
}
|
||||
disabled={isPreview || disabled}
|
||||
className={cn(
|
||||
'min-h-[120px] font-mono text-sm text-transparent caret-foreground placeholder:text-muted-foreground/50',
|
||||
dragHighlight[assignment.id] && 'ring-2 ring-blue-500 ring-offset-2'
|
||||
)}
|
||||
style={{
|
||||
fontFamily: 'inherit',
|
||||
lineHeight: 'inherit',
|
||||
wordBreak: 'break-word',
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
onDrop={(e) => handleDrop(e, assignment.id)}
|
||||
onDragOver={(e) => handleDragOver(e, assignment.id)}
|
||||
onDragLeave={(e) => handleDragLeave(e, assignment.id)}
|
||||
/>
|
||||
<div
|
||||
ref={(el) => {
|
||||
if (el) overlayRefs.current[assignment.id] = el
|
||||
}}
|
||||
className='pointer-events-none absolute inset-0 flex items-start overflow-auto bg-transparent px-3 py-2 font-mono text-sm'
|
||||
style={{
|
||||
fontFamily: 'inherit',
|
||||
lineHeight: 'inherit',
|
||||
}}
|
||||
>
|
||||
<div className='w-full whitespace-pre-wrap break-words'>
|
||||
{formatDisplayText(assignment.value || '', {
|
||||
accessiblePrefixes,
|
||||
highlightAll: !accessiblePrefixes,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='relative'>
|
||||
<Input
|
||||
ref={(el) => {
|
||||
if (el) valueInputRefs.current[assignment.id] = el
|
||||
}}
|
||||
name='value'
|
||||
value={assignment.value || ''}
|
||||
onChange={(e) =>
|
||||
handleValueInputChange(
|
||||
assignment.id,
|
||||
e.target.value,
|
||||
e.target.selectionStart ?? undefined
|
||||
)
|
||||
}
|
||||
placeholder={`${assignment.type} value`}
|
||||
disabled={isPreview || disabled}
|
||||
autoComplete='off'
|
||||
className={cn(
|
||||
'allow-scroll w-full overflow-auto text-transparent caret-foreground',
|
||||
dragHighlight[assignment.id] && 'ring-2 ring-blue-500 ring-offset-2'
|
||||
)}
|
||||
style={{ overflowX: 'auto' }}
|
||||
onDrop={(e) => handleDrop(e, assignment.id)}
|
||||
onDragOver={(e) => handleDragOver(e, assignment.id)}
|
||||
onDragLeave={(e) => handleDragLeave(e, assignment.id)}
|
||||
/>
|
||||
<div
|
||||
ref={(el) => {
|
||||
if (el) overlayRefs.current[assignment.id] = el
|
||||
}}
|
||||
className='pointer-events-none absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[8px] py-[6px] font-medium font-sans text-sm'
|
||||
style={{ overflowX: 'auto' }}
|
||||
>
|
||||
<div
|
||||
className='w-full whitespace-pre'
|
||||
style={{ scrollbarWidth: 'none', minWidth: 'fit-content' }}
|
||||
>
|
||||
{formatDisplayText(
|
||||
assignment.value || '',
|
||||
accessiblePrefixes ? { accessiblePrefixes } : { highlightAll: true }
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showTags && activeFieldId === assignment.id && (
|
||||
<TagDropdown
|
||||
visible={showTags}
|
||||
onSelect={handleTagSelect}
|
||||
blockId={blockId}
|
||||
activeSourceBlockId={activeSourceBlockId}
|
||||
inputValue={assignment.value || ''}
|
||||
cursorPosition={cursorPosition}
|
||||
onClose={() => setShowTags(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isPreview && !disabled && !hasNoWorkflowVariables && (
|
||||
<Button
|
||||
onClick={addAssignment}
|
||||
variant='outline'
|
||||
className='h-9 w-full border-dashed'
|
||||
disabled={allVariablesAssigned}
|
||||
>
|
||||
<Plus className='mr-2 h-4 w-4' />
|
||||
{allVariablesAssigned ? 'All Variables Assigned' : 'Add Variable Assignment'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -210,9 +210,14 @@ export function Editor() {
|
||||
/>
|
||||
) : (
|
||||
<h2
|
||||
className='min-w-0 flex-1 cursor-pointer truncate pr-[8px] font-medium text-[14px] text-[var(--white)] dark:text-[var(--white)]'
|
||||
className='min-w-0 flex-1 cursor-pointer select-none truncate pr-[8px] font-medium text-[14px] text-[var(--white)] dark:text-[var(--white)]'
|
||||
title={title}
|
||||
onDoubleClick={handleStartRename}
|
||||
onMouseDown={(e) => {
|
||||
if (e.detail === 2) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '@/lib/workflows/references'
|
||||
import { checkTagTrigger } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
|
||||
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
|
||||
import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { normalizeBlockName } from '@/stores/workflows/utils'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
@@ -133,13 +134,14 @@ export function useSubflowEditor(currentBlock: BlockState | null, currentBlockId
|
||||
|
||||
let processedCode = code
|
||||
|
||||
processedCode = processedCode.replace(/\{\{([^}]+)\}\}/g, (match) => {
|
||||
processedCode = processedCode.replace(createEnvVarPattern(), (match) => {
|
||||
const placeholder = `__ENV_VAR_${placeholders.length}__`
|
||||
placeholders.push({ placeholder, original: match, type: 'env' })
|
||||
return placeholder
|
||||
})
|
||||
|
||||
processedCode = processedCode.replace(/<[^>]+>/g, (match) => {
|
||||
// Use [^<>]+ to prevent matching across nested brackets (e.g., "<3 <real.ref>" should match separately)
|
||||
processedCode = processedCode.replace(createReferencePattern(), (match) => {
|
||||
if (shouldHighlightReference(match)) {
|
||||
const placeholder = `__VAR_REF_${placeholders.length}__`
|
||||
placeholders.push({ placeholder, original: match, type: 'var' })
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Braces, Square } from 'lucide-react'
|
||||
import { ArrowDown, Braces, Square } from 'lucide-react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import {
|
||||
BubbleChatPreview,
|
||||
@@ -22,12 +22,13 @@ import {
|
||||
PopoverTrigger,
|
||||
Trash,
|
||||
} from '@/components/emcn'
|
||||
import { VariableIcon } from '@/components/icons'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { Variables } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/variables/variables'
|
||||
import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution'
|
||||
import { useDeleteWorkflow } from '@/app/workspace/[workspaceId]/w/hooks'
|
||||
import { useDeleteWorkflow, useImportWorkflow } from '@/app/workspace/[workspaceId]/w/hooks'
|
||||
import { useChatStore } from '@/stores/chat/store'
|
||||
import { usePanelStore } from '@/stores/panel-new/store'
|
||||
import type { PanelTab } from '@/stores/panel-new/types'
|
||||
@@ -62,6 +63,7 @@ export function Panel() {
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
const panelRef = useRef<HTMLElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const { activeTab, setActiveTab, panelWidth, _hasHydrated, setHasHydrated } = usePanelStore()
|
||||
const copilotRef = useRef<{
|
||||
createNewChat: () => void
|
||||
@@ -77,6 +79,7 @@ export function Panel() {
|
||||
|
||||
// Hooks
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
const { isImporting, handleFileChange } = useImportWorkflow({ workspaceId })
|
||||
const {
|
||||
workflows,
|
||||
activeWorkflowId,
|
||||
@@ -262,6 +265,14 @@ export function Panel() {
|
||||
workspaceId,
|
||||
])
|
||||
|
||||
/**
|
||||
* Handles triggering file input for workflow import
|
||||
*/
|
||||
const handleImportWorkflow = useCallback(() => {
|
||||
setIsMenuOpen(false)
|
||||
fileInputRef.current?.click()
|
||||
}, [])
|
||||
|
||||
// Compute run button state
|
||||
const canRun = userPermissions.canRead // Running only requires read permissions
|
||||
const isLoadingPermissions = userPermissions.isLoading
|
||||
@@ -314,7 +325,7 @@ export function Panel() {
|
||||
</PopoverItem>
|
||||
{
|
||||
<PopoverItem onClick={() => setVariablesOpen(!isVariablesOpen)}>
|
||||
<Braces className='h-3 w-3' />
|
||||
<VariableIcon className='h-3 w-3' />
|
||||
<span>Variables</span>
|
||||
</PopoverItem>
|
||||
}
|
||||
@@ -331,7 +342,14 @@ export function Panel() {
|
||||
disabled={isExporting || !currentWorkflow}
|
||||
>
|
||||
<Braces className='h-3 w-3' />
|
||||
<span>Export JSON</span>
|
||||
<span>Export workflow</span>
|
||||
</PopoverItem>
|
||||
<PopoverItem
|
||||
onClick={handleImportWorkflow}
|
||||
disabled={isImporting || !userPermissions.canEdit}
|
||||
>
|
||||
<ArrowDown className='h-3 w-3' />
|
||||
<span>Import workflow</span>
|
||||
</PopoverItem>
|
||||
<PopoverItem
|
||||
onClick={handleDuplicateWorkflow}
|
||||
@@ -499,6 +517,16 @@ export function Panel() {
|
||||
|
||||
{/* Floating Variables Modal */}
|
||||
<Variables />
|
||||
|
||||
{/* Hidden file input for workflow import */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type='file'
|
||||
accept='.json,.zip'
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -74,10 +74,10 @@ export const ActionBar = memo(
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'-right-20 absolute top-0',
|
||||
'flex flex-col items-center',
|
||||
'-top-[46px] absolute right-0',
|
||||
'flex flex-row items-center',
|
||||
'opacity-0 transition-opacity duration-200 group-hover:opacity-100',
|
||||
'gap-[6px] rounded-[10px] bg-[var(--surface-3)] p-[6px]'
|
||||
'gap-[5px] rounded-[10px] bg-[var(--surface-3)] p-[5px]'
|
||||
)}
|
||||
>
|
||||
<Tooltip.Root>
|
||||
@@ -90,17 +90,17 @@ export const ActionBar = memo(
|
||||
collaborativeToggleBlockEnabled(blockId)
|
||||
}
|
||||
}}
|
||||
className='h-[30px] w-[30px] rounded-[8px] bg-[var(--surface-9)] p-0 text-[#868686] hover:bg-[var(--brand-secondary)] hover:text-[var(--bg)] dark:text-[#868686] dark:hover:bg-[var(--brand-secondary)] dark:hover:text-[var(--bg)]'
|
||||
className='h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-9)] p-0 text-[#868686] hover:bg-[var(--brand-secondary)] hover:text-[var(--bg)] dark:text-[#868686] dark:hover:bg-[var(--brand-secondary)] dark:hover:text-[var(--bg)]'
|
||||
disabled={disabled}
|
||||
>
|
||||
{isEnabled ? (
|
||||
<Circle className='h-[14px] w-[14px]' />
|
||||
<Circle className='h-[11px] w-[11px]' />
|
||||
) : (
|
||||
<CircleOff className='h-[14px] w-[14px]' />
|
||||
<CircleOff className='h-[11px] w-[11px]' />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='right'>
|
||||
<Tooltip.Content side='top'>
|
||||
{getTooltipMessage(isEnabled ? 'Disable Block' : 'Enable Block')}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
@@ -116,13 +116,13 @@ export const ActionBar = memo(
|
||||
collaborativeDuplicateBlock(blockId)
|
||||
}
|
||||
}}
|
||||
className='h-[30px] w-[30px] rounded-[8px] bg-[var(--surface-9)] p-0 text-[#868686] hover:bg-[var(--brand-secondary)] hover:text-[var(--bg)] dark:text-[#868686] dark:hover:bg-[var(--brand-secondary)] dark:hover:text-[var(--bg)]'
|
||||
className='h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-9)] p-0 text-[#868686] hover:bg-[var(--brand-secondary)] hover:text-[var(--bg)] dark:text-[#868686] dark:hover:bg-[var(--brand-secondary)] dark:hover:text-[var(--bg)]'
|
||||
disabled={disabled}
|
||||
>
|
||||
<Duplicate className='h-[14px] w-[14px]' />
|
||||
<Duplicate className='h-[11px] w-[11px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='right'>{getTooltipMessage('Duplicate Block')}</Tooltip.Content>
|
||||
<Tooltip.Content side='top'>{getTooltipMessage('Duplicate Block')}</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
|
||||
@@ -139,15 +139,13 @@ export const ActionBar = memo(
|
||||
)
|
||||
}
|
||||
}}
|
||||
className='h-[30px] w-[30px] rounded-[8px] bg-[var(--surface-9)] p-0 text-[#868686] hover:bg-[var(--brand-secondary)] hover:text-[var(--bg)] dark:text-[#868686] dark:hover:bg-[var(--brand-secondary)] dark:hover:text-[var(--bg)]'
|
||||
className='h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-9)] p-0 text-[#868686] hover:bg-[var(--brand-secondary)] hover:text-[var(--bg)] dark:text-[#868686] dark:hover:bg-[var(--brand-secondary)] dark:hover:text-[var(--bg)]'
|
||||
disabled={disabled || !userPermissions.canEdit}
|
||||
>
|
||||
<LogOut className='h-[14px] w-[14px]' />
|
||||
<LogOut className='h-[11px] w-[11px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='right'>
|
||||
{getTooltipMessage('Remove From Subflow')}
|
||||
</Tooltip.Content>
|
||||
<Tooltip.Content side='top'>{getTooltipMessage('Remove from Subflow')}</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
|
||||
@@ -161,17 +159,17 @@ export const ActionBar = memo(
|
||||
collaborativeToggleBlockHandles(blockId)
|
||||
}
|
||||
}}
|
||||
className='h-[30px] w-[30px] rounded-[8px] bg-[var(--surface-9)] p-0 text-[#868686] hover:bg-[var(--brand-secondary)] hover:text-[var(--bg)] dark:text-[#868686] dark:hover:bg-[var(--brand-secondary)] dark:hover:text-[var(--bg)]'
|
||||
className='h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-9)] p-0 text-[#868686] hover:bg-[var(--brand-secondary)] hover:text-[var(--bg)] dark:text-[#868686] dark:hover:bg-[var(--brand-secondary)] dark:hover:text-[var(--bg)]'
|
||||
disabled={disabled}
|
||||
>
|
||||
{horizontalHandles ? (
|
||||
<ArrowLeftRight className='h-[14px] w-[14px]' />
|
||||
<ArrowLeftRight className='h-[11px] w-[11px]' />
|
||||
) : (
|
||||
<ArrowUpDown className='h-[14px] w-[14px]' />
|
||||
<ArrowUpDown className='h-[11px] w-[11px]' />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='right'>
|
||||
<Tooltip.Content side='top'>
|
||||
{getTooltipMessage(horizontalHandles ? 'Vertical Ports' : 'Horizontal Ports')}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
@@ -186,13 +184,13 @@ export const ActionBar = memo(
|
||||
collaborativeRemoveBlock(blockId)
|
||||
}
|
||||
}}
|
||||
className='h-[30px] w-[30px] rounded-[8px] bg-[var(--surface-9)] p-0 text-[#868686] hover:bg-[var(--brand-secondary)] hover:text-[var(--bg)] dark:text-[#868686] dark:hover:bg-[var(--brand-secondary)] dark:hover:text-[var(--bg)] '
|
||||
className='h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-9)] p-0 text-[#868686] hover:bg-[var(--brand-secondary)] hover:text-[var(--bg)] dark:text-[#868686] dark:hover:bg-[var(--brand-secondary)] dark:hover:text-[var(--bg)] '
|
||||
disabled={disabled}
|
||||
>
|
||||
<Trash2 className='h-[14px] w-[14px]' />
|
||||
<Trash2 className='h-[11px] w-[11px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='right'>{getTooltipMessage('Delete Block')}</Tooltip.Content>
|
||||
<Tooltip.Content side='top'>{getTooltipMessage('Delete Block')}</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,84 +1,23 @@
|
||||
import { RepeatIcon, SplitIcon } from 'lucide-react'
|
||||
import {
|
||||
type ConnectedBlock,
|
||||
useBlockConnections,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/hooks/use-block-connections'
|
||||
import { getBlock } from '@/blocks'
|
||||
import { useBlockConnections } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/hooks/use-block-connections'
|
||||
|
||||
interface ConnectionsProps {
|
||||
blockId: string
|
||||
horizontalHandles: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the icon component for a given connection block
|
||||
* @param connection - The connected block to get the icon for
|
||||
* @returns The icon component or null if not found
|
||||
* Displays incoming connections at the bottom left of the workflow block
|
||||
*/
|
||||
function getConnectionIcon(connection: ConnectedBlock) {
|
||||
const blockConfig = getBlock(connection.type)
|
||||
|
||||
if (blockConfig?.icon) {
|
||||
return blockConfig.icon
|
||||
}
|
||||
|
||||
if (connection.type === 'loop') {
|
||||
return RepeatIcon
|
||||
}
|
||||
|
||||
if (connection.type === 'parallel') {
|
||||
return SplitIcon
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays incoming connections as compact floating text above the workflow block
|
||||
*/
|
||||
export function Connections({ blockId, horizontalHandles }: ConnectionsProps) {
|
||||
export function Connections({ blockId }: ConnectionsProps) {
|
||||
const { incomingConnections, hasIncomingConnections } = useBlockConnections(blockId)
|
||||
|
||||
if (!hasIncomingConnections) return null
|
||||
|
||||
const connectionCount = incomingConnections.length
|
||||
const maxVisibleIcons = 4
|
||||
const visibleConnections = incomingConnections.slice(0, maxVisibleIcons)
|
||||
const remainingCount = connectionCount - maxVisibleIcons
|
||||
|
||||
const connectionText = `${connectionCount} ${connectionCount === 1 ? 'connection' : 'connections'}`
|
||||
|
||||
const connectionIcons = (
|
||||
<>
|
||||
{visibleConnections.map((connection: ConnectedBlock) => {
|
||||
const Icon = getConnectionIcon(connection)
|
||||
if (!Icon) return null
|
||||
return (
|
||||
<Icon key={connection.id} className='h-[14px] w-[14px] text-[var(--text-tertiary)]' />
|
||||
)
|
||||
})}
|
||||
{remainingCount > 0 && (
|
||||
<span className='text-[14px] text-[var(--text-tertiary)]'>+{remainingCount}</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
if (!horizontalHandles) {
|
||||
return (
|
||||
<div className='-translate-x-full -translate-y-1/2 pointer-events-none absolute top-1/2 left-0 flex flex-col items-end gap-[8px] pr-[8px] opacity-0 transition-opacity group-hover:opacity-100'>
|
||||
<span className='text-[14px] text-[var(--text-tertiary)] leading-[14px]'>
|
||||
{connectionText}
|
||||
</span>
|
||||
<div className='flex items-center justify-end gap-[4px]'>{connectionIcons}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='pointer-events-none absolute bottom-full left-0 ml-[8px] flex items-center gap-[8px] pb-[8px] opacity-0 transition-opacity group-hover:opacity-100'>
|
||||
<span className='text-[14px] text-[var(--text-tertiary)]'>{connectionText}</span>
|
||||
<div className='h-[14px] w-[1px] bg-[var(--text-tertiary)]' />
|
||||
<div className='flex items-center gap-[4px]'>{connectionIcons}</div>
|
||||
<div className='pointer-events-none absolute top-full left-0 ml-[8px] flex items-center gap-[8px] pt-[8px] opacity-0 transition-opacity group-hover:opacity-100'>
|
||||
<span className='text-[12px] text-[var(--text-tertiary)]'>{connectionText}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
@@ -343,6 +370,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
handleClick,
|
||||
hasRing,
|
||||
ringStyles,
|
||||
runPathStatus,
|
||||
} = useBlockCore({ blockId: id, data, isPending })
|
||||
|
||||
const currentBlock = currentWorkflow.getBlockById(id)
|
||||
@@ -722,9 +750,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
|
||||
<ActionBar blockId={id} blockType={type} disabled={!userPermissions.canEdit} />
|
||||
|
||||
{shouldShowDefaultHandles && (
|
||||
<Connections blockId={id} horizontalHandles={horizontalHandles} />
|
||||
)}
|
||||
{shouldShowDefaultHandles && <Connections blockId={id} />}
|
||||
|
||||
{shouldShowDefaultHandles && (
|
||||
<Handle
|
||||
@@ -750,21 +776,26 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
e.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-[10px]'>
|
||||
<div className='relative z-10 flex min-w-0 flex-1 items-center gap-[10px]'>
|
||||
<div
|
||||
className='flex h-[24px] w-[24px] flex-shrink-0 items-center justify-center rounded-[6px]'
|
||||
style={{ backgroundColor: isEnabled ? config.bgColor : 'gray' }}
|
||||
style={{
|
||||
backgroundColor: isEnabled ? config.bgColor : 'gray',
|
||||
}}
|
||||
>
|
||||
<config.icon className='h-[16px] w-[16px] text-white' />
|
||||
</div>
|
||||
<span
|
||||
className={cn('truncate font-medium text-[16px]', !isEnabled && 'text-[#808080]')}
|
||||
className={cn(
|
||||
'truncate font-medium text-[16px]',
|
||||
!isEnabled && runPathStatus !== 'success' && 'text-[#808080]'
|
||||
)}
|
||||
title={name}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex flex-shrink-0 items-center gap-2'>
|
||||
<div className='relative z-10 flex flex-shrink-0 items-center gap-2'>
|
||||
{isWorkflowSelector && childWorkflowId && (
|
||||
<>
|
||||
{typeof childIsDeployed === 'boolean' ? (
|
||||
@@ -890,6 +921,14 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
{/* {isActive && (
|
||||
<div className='mr-[2px] ml-2 flex h-[16px] w-[16px] items-center justify-center'>
|
||||
<div
|
||||
className='h-full w-full animate-spin-slow rounded-full border-[2.5px] border-[rgba(255,102,0,0.25)] border-t-[var(--warning)]'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
</div>
|
||||
)} */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { X } from 'lucide-react'
|
||||
import { BaseEdge, EdgeLabelRenderer, type EdgeProps, getSmoothStepPath } from 'reactflow'
|
||||
import type { EdgeDiffStatus } from '@/lib/workflows/diff/types'
|
||||
import { useExecutionStore } from '@/stores/execution/store'
|
||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
|
||||
|
||||
interface WorkflowEdgeProps extends EdgeProps {
|
||||
@@ -43,6 +44,7 @@ export const WorkflowEdge = ({
|
||||
const diffAnalysis = useWorkflowDiffStore((state) => state.diffAnalysis)
|
||||
const isShowingDiff = useWorkflowDiffStore((state) => state.isShowingDiff)
|
||||
const isDiffReady = useWorkflowDiffStore((state) => state.isDiffReady)
|
||||
const lastRunEdges = useExecutionStore((state) => state.lastRunEdges)
|
||||
|
||||
const generateEdgeIdentity = (
|
||||
sourceId: string,
|
||||
@@ -78,10 +80,16 @@ export const WorkflowEdge = ({
|
||||
const dataSourceHandle = (data as { sourceHandle?: string } | undefined)?.sourceHandle
|
||||
const isErrorEdge = (sourceHandle ?? dataSourceHandle) === 'error'
|
||||
|
||||
// Check if this edge was traversed during last execution
|
||||
const edgeRunStatus = lastRunEdges.get(id)
|
||||
|
||||
const getEdgeColor = () => {
|
||||
if (edgeDiffStatus === 'deleted') return 'var(--text-error)'
|
||||
if (isErrorEdge) return 'var(--text-error)'
|
||||
if (edgeDiffStatus === 'new') return 'var(--brand-tertiary)'
|
||||
// Show run path status if edge was traversed
|
||||
if (edgeRunStatus === 'success') return 'var(--border-success)'
|
||||
if (edgeRunStatus === 'error') return 'var(--text-error)'
|
||||
return 'var(--surface-12)'
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ export function useAccessibleReferencePrefixes(blockId?: string | null): Set<str
|
||||
const starterBlock = Object.values(blocks).find(
|
||||
(block) => block.type === 'starter' || block.type === 'start_trigger'
|
||||
)
|
||||
if (starterBlock) {
|
||||
if (starterBlock && ancestorIds.includes(starterBlock.id)) {
|
||||
accessibleIds.add(starterBlock.id)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useExecutionStore } from '@/stores/execution/store'
|
||||
import { usePanelEditorStore } from '@/stores/panel-new/editor/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useBlockState } from '../components/workflow-block/hooks'
|
||||
import type { WorkflowBlockProps } from '../components/workflow-block/types'
|
||||
import { getBlockRingStyles } from '../utils/block-ring-utils'
|
||||
import { useCurrentWorkflow } from './use-current-workflow'
|
||||
|
||||
interface UseBlockCoreOptions {
|
||||
@@ -43,60 +43,19 @@ export function useBlockCore({ blockId, data, isPending = false }: UseBlockCoreO
|
||||
}, [blockId, setCurrentBlockId])
|
||||
|
||||
// Ring styling based on all states
|
||||
// Priority: active (animated) > pending > focused > deleted > diff > run path
|
||||
const { hasRing, ringStyles } = useMemo(() => {
|
||||
const hasRing =
|
||||
isActive ||
|
||||
isPending ||
|
||||
isFocused ||
|
||||
diffStatus === 'new' ||
|
||||
diffStatus === 'edited' ||
|
||||
isDeletedBlock ||
|
||||
!!runPathStatus
|
||||
|
||||
const ringStyles = cn(
|
||||
// Executing block: animated ring cycling through gray tones (animation handles all styling)
|
||||
isActive && 'animate-ring-pulse',
|
||||
// Non-active states use standard ring utilities
|
||||
!isActive && hasRing && 'ring-[1.75px]',
|
||||
// Pending state: warning ring
|
||||
!isActive && isPending && 'ring-[var(--warning)]',
|
||||
// Focused (selected) state: brand ring
|
||||
!isActive && !isPending && isFocused && 'ring-[var(--brand-secondary)]',
|
||||
// Deleted state (highest priority after active/pending/focused)
|
||||
!isActive && !isPending && !isFocused && isDeletedBlock && 'ring-[var(--text-error)]',
|
||||
// Diff states
|
||||
!isActive &&
|
||||
!isPending &&
|
||||
!isFocused &&
|
||||
!isDeletedBlock &&
|
||||
diffStatus === 'new' &&
|
||||
'ring-[#22C55E]',
|
||||
!isActive &&
|
||||
!isPending &&
|
||||
!isFocused &&
|
||||
!isDeletedBlock &&
|
||||
diffStatus === 'edited' &&
|
||||
'ring-[var(--warning)]',
|
||||
// Run path states (lowest priority - only show if no other states active)
|
||||
!isActive &&
|
||||
!isPending &&
|
||||
!isFocused &&
|
||||
!isDeletedBlock &&
|
||||
!diffStatus &&
|
||||
runPathStatus === 'success' &&
|
||||
'ring-[var(--surface-14)]',
|
||||
!isActive &&
|
||||
!isPending &&
|
||||
!isFocused &&
|
||||
!isDeletedBlock &&
|
||||
!diffStatus &&
|
||||
runPathStatus === 'error' &&
|
||||
'ring-[var(--text-error)]'
|
||||
)
|
||||
|
||||
return { hasRing, ringStyles }
|
||||
}, [isActive, isPending, isFocused, diffStatus, isDeletedBlock, runPathStatus])
|
||||
// Priority: active (executing) > pending > focused > deleted > diff > run path
|
||||
const { hasRing, ringClassName: ringStyles } = useMemo(
|
||||
() =>
|
||||
getBlockRingStyles({
|
||||
isActive,
|
||||
isPending,
|
||||
isFocused,
|
||||
isDeletedBlock,
|
||||
diffStatus,
|
||||
runPathStatus,
|
||||
}),
|
||||
[isActive, isPending, isFocused, isDeletedBlock, diffStatus, runPathStatus]
|
||||
)
|
||||
|
||||
return {
|
||||
// Workflow context
|
||||
@@ -116,5 +75,6 @@ export function useBlockCore({ blockId, data, isPending = false }: UseBlockCoreO
|
||||
// Ring styling
|
||||
hasRing,
|
||||
ringStyles,
|
||||
runPathStatus,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ const DEFAULT_CONTAINER_HEIGHT = 300
|
||||
* Hook providing utilities for node position, hierarchy, and dimension calculations
|
||||
*/
|
||||
export function useNodeUtilities(blocks: Record<string, any>) {
|
||||
const { getNodes, project } = useReactFlow()
|
||||
const { getNodes } = useReactFlow()
|
||||
|
||||
/**
|
||||
* Check if a block is a container type (loop, parallel, or subflow)
|
||||
|
||||
@@ -100,6 +100,7 @@ export function useWorkflowExecution() {
|
||||
setDebugContext,
|
||||
setActiveBlocks,
|
||||
setBlockRunStatus,
|
||||
setEdgeRunStatus,
|
||||
} = useExecutionStore()
|
||||
const [executionResult, setExecutionResult] = useState<ExecutionResult | null>(null)
|
||||
const executionStream = useExecutionStream()
|
||||
@@ -681,10 +682,10 @@ export function useWorkflowExecution() {
|
||||
const workflowEdges = (executionWorkflowState?.edges ??
|
||||
latestWorkflowState.edges) as typeof currentWorkflow.edges
|
||||
|
||||
// Filter out blocks without type (these are layout-only blocks)
|
||||
// Filter out blocks without type (these are layout-only blocks) and disabled blocks
|
||||
const validBlocks = Object.entries(workflowBlocks).reduce(
|
||||
(acc, [blockId, block]) => {
|
||||
if (block?.type) {
|
||||
if (block?.type && block.enabled !== false) {
|
||||
acc[blockId] = block
|
||||
}
|
||||
return acc
|
||||
@@ -724,13 +725,18 @@ export function useWorkflowExecution() {
|
||||
}
|
||||
})
|
||||
|
||||
// Do not filter out trigger blocks; executor may need to start from them
|
||||
// Filter out blocks without type and disabled blocks
|
||||
const filteredStates = Object.entries(mergedStates).reduce(
|
||||
(acc, [id, block]) => {
|
||||
if (!block || !block.type) {
|
||||
logger.warn(`Skipping block with undefined type: ${id}`, block)
|
||||
return acc
|
||||
}
|
||||
// Skip disabled blocks to prevent them from being passed to executor
|
||||
if (block.enabled === false) {
|
||||
logger.warn(`Skipping disabled block: ${id}`)
|
||||
return acc
|
||||
}
|
||||
acc[id] = block
|
||||
return acc
|
||||
},
|
||||
@@ -892,6 +898,12 @@ export function useWorkflowExecution() {
|
||||
activeBlocksSet.add(data.blockId)
|
||||
// Create a new Set to trigger React re-render
|
||||
setActiveBlocks(new Set(activeBlocksSet))
|
||||
|
||||
// Track edges that led to this block as soon as execution starts
|
||||
const incomingEdges = workflowEdges.filter((edge) => edge.target === data.blockId)
|
||||
incomingEdges.forEach((edge) => {
|
||||
setEdgeRunStatus(edge.id, 'success')
|
||||
})
|
||||
},
|
||||
|
||||
onBlockCompleted: (data) => {
|
||||
@@ -904,6 +916,8 @@ export function useWorkflowExecution() {
|
||||
// Track successful block execution in run path
|
||||
setBlockRunStatus(data.blockId, 'success')
|
||||
|
||||
// Edges already tracked in onBlockStarted, no need to track again
|
||||
|
||||
// Add to console
|
||||
addConsole({
|
||||
input: data.input || {},
|
||||
@@ -938,7 +952,6 @@ export function useWorkflowExecution() {
|
||||
|
||||
// Track failed block execution in run path
|
||||
setBlockRunStatus(data.blockId, 'error')
|
||||
|
||||
// Add error to console
|
||||
addConsole({
|
||||
input: data.input || {},
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type BlockDiffStatus = 'new' | 'edited' | null | undefined
|
||||
|
||||
export type BlockRunPathStatus = 'success' | 'error' | undefined
|
||||
|
||||
export interface BlockRingOptions {
|
||||
isActive: boolean
|
||||
isPending: boolean
|
||||
isFocused: boolean
|
||||
isDeletedBlock: boolean
|
||||
diffStatus: BlockDiffStatus
|
||||
runPathStatus: BlockRunPathStatus
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives visual ring visibility and class names for workflow blocks
|
||||
* based on execution, focus, diff, deletion, and run-path states.
|
||||
*/
|
||||
export function getBlockRingStyles(options: BlockRingOptions): {
|
||||
hasRing: boolean
|
||||
ringClassName: string
|
||||
} {
|
||||
const { isActive, isPending, isFocused, isDeletedBlock, diffStatus, runPathStatus } = options
|
||||
|
||||
const hasRing =
|
||||
isActive ||
|
||||
isPending ||
|
||||
isFocused ||
|
||||
diffStatus === 'new' ||
|
||||
diffStatus === 'edited' ||
|
||||
isDeletedBlock ||
|
||||
!!runPathStatus
|
||||
|
||||
const ringClassName = cn(
|
||||
// Executing block: pulsing success ring with prominent thickness
|
||||
isActive && 'ring-[3.5px] ring-[var(--border-success)] animate-ring-pulse',
|
||||
// Non-active states use standard ring utilities
|
||||
!isActive && hasRing && 'ring-[1.75px]',
|
||||
// Pending state: warning ring
|
||||
!isActive && isPending && 'ring-[var(--warning)]',
|
||||
// Focused (selected) state: brand ring
|
||||
!isActive && !isPending && isFocused && 'ring-[var(--brand-secondary)]',
|
||||
// Deleted state (highest priority after active/pending/focused)
|
||||
!isActive && !isPending && !isFocused && isDeletedBlock && 'ring-[var(--text-error)]',
|
||||
// Diff states
|
||||
!isActive &&
|
||||
!isPending &&
|
||||
!isFocused &&
|
||||
!isDeletedBlock &&
|
||||
diffStatus === 'new' &&
|
||||
'ring-[#22C55E]',
|
||||
!isActive &&
|
||||
!isPending &&
|
||||
!isFocused &&
|
||||
!isDeletedBlock &&
|
||||
diffStatus === 'edited' &&
|
||||
'ring-[var(--warning)]',
|
||||
// Run path states (lowest priority - only show if no other states active)
|
||||
!isActive &&
|
||||
!isPending &&
|
||||
!isFocused &&
|
||||
!isDeletedBlock &&
|
||||
!diffStatus &&
|
||||
runPathStatus === 'success' &&
|
||||
'ring-[var(--border-success)]',
|
||||
!isActive &&
|
||||
!isPending &&
|
||||
!isFocused &&
|
||||
!isDeletedBlock &&
|
||||
!diffStatus &&
|
||||
runPathStatus === 'error' &&
|
||||
'ring-[var(--text-error)]'
|
||||
)
|
||||
|
||||
return { hasRing, ringClassName }
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './auto-layout-utils'
|
||||
export * from './block-ring-utils'
|
||||
export * from './workflow-execution-utils'
|
||||
|
||||
@@ -110,14 +110,13 @@ const WorkflowContent = React.memo(() => {
|
||||
// Hooks
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const { project, getNodes, fitView } = useReactFlow()
|
||||
const { screenToFlowPosition, getNodes, fitView } = useReactFlow()
|
||||
const { emitCursorUpdate } = useSocket()
|
||||
|
||||
// Get workspace ID from the params
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
const { workflows, activeWorkflowId, isLoading, setActiveWorkflow, createWorkflow } =
|
||||
useWorkflowRegistry()
|
||||
const { workflows, activeWorkflowId, isLoading, setActiveWorkflow } = useWorkflowRegistry()
|
||||
|
||||
// Use the clean abstraction for current workflow state
|
||||
const currentWorkflow = useCurrentWorkflow()
|
||||
@@ -170,7 +169,7 @@ const WorkflowContent = React.memo(() => {
|
||||
// Get diff analysis for edge reconstruction
|
||||
const { diffAnalysis, isShowingDiff, isDiffReady } = useWorkflowDiffStore()
|
||||
|
||||
// Reconstruct deleted edges when viewing original workflow and filter trigger edges
|
||||
// Reconstruct deleted edges when viewing original workflow and filter out invalid edges
|
||||
const edgesForDisplay = useMemo(() => {
|
||||
let edgesToFilter = edges
|
||||
|
||||
@@ -237,7 +236,21 @@ const WorkflowContent = React.memo(() => {
|
||||
// Combine existing edges with reconstructed deleted edges
|
||||
edgesToFilter = [...edges, ...reconstructedEdges]
|
||||
}
|
||||
return edgesToFilter
|
||||
|
||||
// Filter out edges that connect to/from annotation-only blocks (note blocks)
|
||||
// These blocks don't have handles and shouldn't have connections
|
||||
return edgesToFilter.filter((edge) => {
|
||||
const sourceBlock = blocks[edge.source]
|
||||
const targetBlock = blocks[edge.target]
|
||||
|
||||
// Remove edge if either source or target is an annotation-only block
|
||||
if (!sourceBlock || !targetBlock) return false
|
||||
if (isAnnotationOnlyBlock(sourceBlock.type) || isAnnotationOnlyBlock(targetBlock.type)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
}, [edges, isShowingDiff, isDiffReady, diffAnalysis, blocks])
|
||||
|
||||
// User permissions - get current user's specific permissions from context
|
||||
@@ -421,6 +434,7 @@ const WorkflowContent = React.memo(() => {
|
||||
activeElement?.hasAttribute('contenteditable')
|
||||
|
||||
if (isEditableElement) {
|
||||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -680,7 +694,11 @@ const WorkflowContent = React.memo(() => {
|
||||
// Auto-connect logic for blocks inside containers
|
||||
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
|
||||
let autoConnectEdge
|
||||
if (isAutoConnectEnabled && data.type !== 'starter') {
|
||||
if (
|
||||
isAutoConnectEnabled &&
|
||||
data.type !== 'starter' &&
|
||||
!isAnnotationOnlyBlock(data.type)
|
||||
) {
|
||||
if (existingChildBlocks.length > 0) {
|
||||
// Connect to the nearest existing child block within the container
|
||||
const closestBlock = existingChildBlocks
|
||||
@@ -694,7 +712,7 @@ const WorkflowContent = React.memo(() => {
|
||||
.sort((a, b) => a.distance - b.distance)[0]?.block
|
||||
|
||||
if (closestBlock) {
|
||||
// Don't create edges into trigger blocks
|
||||
// Don't create edges into trigger blocks or annotation blocks
|
||||
const targetBlockConfig = getBlock(data.type)
|
||||
const isTargetTrigger =
|
||||
data.enableTriggerMode === true || targetBlockConfig?.category === 'triggers'
|
||||
@@ -769,10 +787,14 @@ const WorkflowContent = React.memo(() => {
|
||||
// Regular auto-connect logic
|
||||
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
|
||||
let autoConnectEdge
|
||||
if (isAutoConnectEnabled && data.type !== 'starter') {
|
||||
if (
|
||||
isAutoConnectEnabled &&
|
||||
data.type !== 'starter' &&
|
||||
!isAnnotationOnlyBlock(data.type)
|
||||
) {
|
||||
const closestBlock = findClosestOutput(position)
|
||||
if (closestBlock) {
|
||||
// Don't create edges into trigger blocks
|
||||
// Don't create edges into trigger blocks or annotation blocks
|
||||
const targetBlockConfig = getBlock(data.type)
|
||||
const isTargetTrigger =
|
||||
data.enableTriggerMode === true || targetBlockConfig?.category === 'triggers'
|
||||
@@ -842,7 +864,7 @@ const WorkflowContent = React.memo(() => {
|
||||
const baseName = type === 'loop' ? 'Loop' : 'Parallel'
|
||||
const name = getUniqueBlockName(baseName, blocks)
|
||||
|
||||
const centerPosition = project({
|
||||
const centerPosition = screenToFlowPosition({
|
||||
x: window.innerWidth / 2,
|
||||
y: window.innerHeight / 2,
|
||||
})
|
||||
@@ -891,7 +913,7 @@ const WorkflowContent = React.memo(() => {
|
||||
}
|
||||
|
||||
// Calculate the center position of the viewport
|
||||
const centerPosition = project({
|
||||
const centerPosition = screenToFlowPosition({
|
||||
x: window.innerWidth / 2,
|
||||
y: window.innerHeight / 2,
|
||||
})
|
||||
@@ -906,11 +928,11 @@ const WorkflowContent = React.memo(() => {
|
||||
// Auto-connect logic
|
||||
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
|
||||
let autoConnectEdge
|
||||
if (isAutoConnectEnabled && type !== 'starter') {
|
||||
if (isAutoConnectEnabled && type !== 'starter' && !isAnnotationOnlyBlock(type)) {
|
||||
const closestBlock = findClosestOutput(centerPosition)
|
||||
logger.info('Closest block found:', closestBlock)
|
||||
if (closestBlock) {
|
||||
// Don't create edges into trigger blocks
|
||||
// Don't create edges into trigger blocks or annotation blocks
|
||||
const targetBlockConfig = blockConfig
|
||||
const isTargetTrigger = enableTriggerMode || targetBlockConfig?.category === 'triggers'
|
||||
|
||||
@@ -977,7 +999,7 @@ const WorkflowContent = React.memo(() => {
|
||||
)
|
||||
}
|
||||
}, [
|
||||
project,
|
||||
screenToFlowPosition,
|
||||
blocks,
|
||||
addBlock,
|
||||
addEdge,
|
||||
@@ -1014,7 +1036,7 @@ const WorkflowContent = React.memo(() => {
|
||||
}
|
||||
|
||||
const bounds = canvasElement.getBoundingClientRect()
|
||||
const position = project({
|
||||
const position = screenToFlowPosition({
|
||||
x: detail.clientX - bounds.left,
|
||||
y: detail.clientY - bounds.top,
|
||||
})
|
||||
@@ -1041,7 +1063,7 @@ const WorkflowContent = React.memo(() => {
|
||||
'toolbar-drop-on-empty-workflow-overlay',
|
||||
handleOverlayToolbarDrop as EventListener
|
||||
)
|
||||
}, [project, handleToolbarDrop])
|
||||
}, [screenToFlowPosition, handleToolbarDrop])
|
||||
|
||||
/**
|
||||
* Recenter canvas when diff appears
|
||||
@@ -1090,7 +1112,7 @@ const WorkflowContent = React.memo(() => {
|
||||
if (!data?.type) return
|
||||
|
||||
const reactFlowBounds = event.currentTarget.getBoundingClientRect()
|
||||
const position = project({
|
||||
const position = screenToFlowPosition({
|
||||
x: event.clientX - reactFlowBounds.left,
|
||||
y: event.clientY - reactFlowBounds.top,
|
||||
})
|
||||
@@ -1106,7 +1128,7 @@ const WorkflowContent = React.memo(() => {
|
||||
logger.error('Error dropping block on ReactFlow canvas:', { err })
|
||||
}
|
||||
},
|
||||
[project, handleToolbarDrop]
|
||||
[screenToFlowPosition, handleToolbarDrop]
|
||||
)
|
||||
|
||||
const handleCanvasPointerMove = useCallback(
|
||||
@@ -1114,14 +1136,14 @@ const WorkflowContent = React.memo(() => {
|
||||
const target = event.currentTarget as HTMLElement
|
||||
const bounds = target.getBoundingClientRect()
|
||||
|
||||
const position = project({
|
||||
const position = screenToFlowPosition({
|
||||
x: event.clientX - bounds.left,
|
||||
y: event.clientY - bounds.top,
|
||||
})
|
||||
|
||||
emitCursorUpdate(position)
|
||||
},
|
||||
[project, emitCursorUpdate]
|
||||
[screenToFlowPosition, emitCursorUpdate]
|
||||
)
|
||||
|
||||
const handleCanvasPointerLeave = useCallback(() => {
|
||||
@@ -1144,7 +1166,7 @@ const WorkflowContent = React.memo(() => {
|
||||
|
||||
try {
|
||||
const reactFlowBounds = event.currentTarget.getBoundingClientRect()
|
||||
const position = project({
|
||||
const position = screenToFlowPosition({
|
||||
x: event.clientX - reactFlowBounds.left,
|
||||
y: event.clientY - reactFlowBounds.top,
|
||||
})
|
||||
@@ -1188,21 +1210,34 @@ const WorkflowContent = React.memo(() => {
|
||||
logger.error('Error in onDragOver', { err })
|
||||
}
|
||||
},
|
||||
[project, isPointInLoopNode, getNodes]
|
||||
[screenToFlowPosition, isPointInLoopNode, getNodes]
|
||||
)
|
||||
|
||||
// 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(() => {
|
||||
@@ -1212,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)
|
||||
@@ -1584,8 +1623,8 @@ const WorkflowContent = React.memo(() => {
|
||||
// Store currently dragged node ID
|
||||
setDraggedNodeId(node.id)
|
||||
|
||||
// Emit collaborative position update during drag for smooth real-time movement
|
||||
collaborativeUpdateBlockPosition(node.id, node.position, false)
|
||||
// Note: We don't emit position updates during drag to avoid flooding socket events.
|
||||
// The final position is sent in onNodeDragStop for collaborative updates.
|
||||
|
||||
// Get the current parent ID of the node being dragged
|
||||
const currentParentId = blocks[node.id]?.data?.parentId || null
|
||||
@@ -1721,14 +1760,7 @@ const WorkflowContent = React.memo(() => {
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
getNodes,
|
||||
potentialParentId,
|
||||
blocks,
|
||||
getNodeAbsolutePosition,
|
||||
getNodeDepth,
|
||||
collaborativeUpdateBlockPosition,
|
||||
]
|
||||
[getNodes, potentialParentId, blocks, getNodeAbsolutePosition, getNodeDepth]
|
||||
)
|
||||
|
||||
// Add in a nodeDrag start event to set the dragStartParentId
|
||||
@@ -1855,7 +1887,8 @@ const WorkflowContent = React.memo(() => {
|
||||
|
||||
// Auto-connect when moving an existing block into a container
|
||||
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
|
||||
if (isAutoConnectEnabled) {
|
||||
// Don't auto-connect annotation blocks (like note blocks)
|
||||
if (isAutoConnectEnabled && !isAnnotationOnlyBlock(node.data?.type)) {
|
||||
// Existing children in the target container (excluding the moved node)
|
||||
const existingChildBlocks = Object.values(blocks).filter(
|
||||
(b) => b.data?.parentId === potentialParentId && b.id !== node.id
|
||||
|
||||
@@ -26,7 +26,7 @@ export function Account(_props: AccountProps) {
|
||||
const router = useRouter()
|
||||
const brandConfig = useBrandConfig()
|
||||
|
||||
// React Query hooks - with placeholderData to show cached data immediately (no skeleton loading!)
|
||||
// React Query hooks - with placeholderData to show cached data immediately
|
||||
const { data: profile } = useUserProfile()
|
||||
const updateProfile = useUpdateUserProfile()
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ export function CreatorProfile() {
|
||||
const { data: session } = useSession()
|
||||
const userId = session?.user?.id || ''
|
||||
|
||||
// React Query hooks - with placeholderData to show cached data immediately (no skeleton loading!)
|
||||
// React Query hooks - with placeholderData to show cached data immediately
|
||||
const { data: organizations = [] } = useOrganizations()
|
||||
const { data: existingProfile } = useCreatorProfile(userId)
|
||||
const saveProfile = useSaveCreatorProfile()
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Check, ChevronDown, ExternalLink, Search } from 'lucide-react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { Button } from '@/components/emcn'
|
||||
import { Input, Label } from '@/components/ui'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -26,11 +25,9 @@ interface CredentialsProps {
|
||||
export function Credentials({ onOpenChange, registerCloseHandler }: CredentialsProps) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { data: session } = useSession()
|
||||
const userId = session?.user?.id
|
||||
const pendingServiceRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// React Query hooks - with placeholderData to show cached data immediately (no skeleton loading!)
|
||||
// React Query hooks - with placeholderData to show cached data immediately
|
||||
const { data: services = [] } = useOAuthConnections()
|
||||
const connectService = useConnectOAuthService()
|
||||
const disconnectService = useDisconnectOAuthService()
|
||||
@@ -38,51 +35,28 @@ export function Credentials({ onOpenChange, registerCloseHandler }: CredentialsP
|
||||
// Local UI state
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [pendingService, setPendingService] = useState<string | null>(null)
|
||||
const [_pendingScopes, setPendingScopes] = useState<string[]>([])
|
||||
const [authSuccess, setAuthSuccess] = useState(false)
|
||||
const [showActionRequired, setShowActionRequired] = useState(false)
|
||||
const prevConnectedIdsRef = useRef<Set<string>>(new Set())
|
||||
const connectionAddedRef = useRef<boolean>(false)
|
||||
|
||||
// Check for OAuth callback
|
||||
// Check for OAuth callback - just show success message
|
||||
useEffect(() => {
|
||||
const code = searchParams.get('code')
|
||||
const state = searchParams.get('state')
|
||||
const error = searchParams.get('error')
|
||||
|
||||
// Handle OAuth callback
|
||||
if (code && state) {
|
||||
// This is an OAuth callback - try to restore state from localStorage
|
||||
try {
|
||||
const stored = localStorage.getItem('pending_oauth_state')
|
||||
if (stored) {
|
||||
const oauthState = JSON.parse(stored)
|
||||
logger.info('OAuth callback with restored state:', oauthState)
|
||||
|
||||
// Mark as pending if we have context about what service was being connected
|
||||
if (oauthState.serviceId) {
|
||||
setPendingService(oauthState.serviceId)
|
||||
setShowActionRequired(true)
|
||||
}
|
||||
|
||||
// Clean up the state (one-time use)
|
||||
localStorage.removeItem('pending_oauth_state')
|
||||
} else {
|
||||
logger.warn('OAuth callback but no state found in localStorage')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error loading OAuth state from localStorage:', error)
|
||||
localStorage.removeItem('pending_oauth_state') // Clean up corrupted state
|
||||
}
|
||||
|
||||
// Set success flag
|
||||
logger.info('OAuth callback successful')
|
||||
setAuthSuccess(true)
|
||||
|
||||
// Clear the URL parameters
|
||||
router.replace('/workspace')
|
||||
// Clear URL parameters without changing the page
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.delete('code')
|
||||
url.searchParams.delete('state')
|
||||
router.replace(url.pathname + url.search)
|
||||
} else if (error) {
|
||||
logger.error('OAuth error:', { error })
|
||||
router.replace('/workspace')
|
||||
}
|
||||
}, [searchParams, router])
|
||||
|
||||
@@ -132,6 +106,7 @@ export function Credentials({ onOpenChange, registerCloseHandler }: CredentialsP
|
||||
scopes: service.scopes,
|
||||
})
|
||||
|
||||
// better-auth will automatically redirect back to this URL after OAuth
|
||||
await connectService.mutateAsync({
|
||||
providerId: service.providerId,
|
||||
callbackURL: window.location.href,
|
||||
|
||||
@@ -55,7 +55,7 @@ export function Files() {
|
||||
const params = useParams()
|
||||
const workspaceId = params?.workspaceId as string
|
||||
|
||||
// React Query hooks - with placeholderData to show cached data immediately (no skeleton loading!)
|
||||
// React Query hooks - with placeholderData to show cached data immediately
|
||||
const { data: files = [] } = useWorkspaceFiles(workspaceId)
|
||||
const { data: storageInfo } = useStorageInfo(isBillingEnabled)
|
||||
const uploadFile = useUploadWorkspaceFile()
|
||||
|
||||
@@ -34,7 +34,7 @@ export function General() {
|
||||
const [isSuperUser, setIsSuperUser] = useState(false)
|
||||
const [loadingSuperUser, setLoadingSuperUser] = useState(true)
|
||||
|
||||
// React Query hooks - with placeholderData to show cached data immediately (no skeleton loading!)
|
||||
// React Query hooks - with placeholderData to show cached data immediately
|
||||
const { data: settings, isLoading } = useGeneralSettings()
|
||||
const updateSetting = useUpdateGeneralSetting()
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ const TOOLTIPS = {
|
||||
}
|
||||
|
||||
export function Privacy() {
|
||||
// React Query hooks - with placeholderData to show cached data immediately (no skeleton loading!)
|
||||
// React Query hooks - with placeholderData to show cached data immediately
|
||||
const { data: settings } = useGeneralSettings()
|
||||
const updateSetting = useUpdateGeneralSetting()
|
||||
|
||||
|
||||
@@ -227,12 +227,8 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
|
||||
onClick={() => setIsDialogOpen(true)}
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
'h-8 rounded-[8px] font-medium text-xs transition-all duration-200',
|
||||
error
|
||||
? 'border-red-500 text-red-500 dark:border-red-500 dark:text-red-500'
|
||||
: isCancelAtPeriodEnd
|
||||
? 'text-muted-foreground hover:border-green-500 hover:bg-green-500 hover:text-white dark:hover:border-green-500 dark:hover:bg-green-500'
|
||||
: 'text-muted-foreground hover:border-red-500 hover:bg-red-500 hover:text-white dark:hover:border-red-500 dark:hover:bg-red-500'
|
||||
'h-8 rounded-[8px] font-medium text-xs',
|
||||
error && 'border-red-500 text-red-500 dark:border-red-500 dark:text-red-500'
|
||||
)}
|
||||
>
|
||||
{error ? 'Error' : isCancelAtPeriodEnd ? 'Restore' : 'Manage'}
|
||||
|
||||
@@ -107,12 +107,11 @@ export function PlanCard({
|
||||
<Button
|
||||
onClick={onButtonClick}
|
||||
className={cn(
|
||||
'h-9 rounded-[8px] text-xs transition-colors',
|
||||
'h-9 rounded-[8px] text-xs',
|
||||
isHorizontal ? 'px-4' : 'w-full',
|
||||
isError &&
|
||||
'border-red-500 bg-transparent text-red-500 hover:bg-red-500 hover:text-white dark:border-red-500 dark:text-red-500 dark:hover:bg-red-500'
|
||||
isError && 'border-red-500 text-red-500 dark:border-red-500 dark:text-red-500'
|
||||
)}
|
||||
variant={isError ? 'outline' : 'default'}
|
||||
variant='outline'
|
||||
aria-label={`${buttonText} ${name} plan`}
|
||||
>
|
||||
{isError ? 'Error' : buttonText}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useUsageLimits } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/hooks'
|
||||
import { useUpdateUsageLimit } from '@/hooks/queries/subscription'
|
||||
|
||||
const logger = createLogger('UsageLimit')
|
||||
|
||||
@@ -42,20 +43,22 @@ export const UsageLimit = forwardRef<UsageLimitRef, UsageLimitProps>(
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Use centralized usage limits hook
|
||||
const { updateLimit, isUpdating } = useUsageLimits({
|
||||
const { updateLimit, isUpdating: isOrgUpdating } = useUsageLimits({
|
||||
context,
|
||||
organizationId,
|
||||
autoRefresh: false, // Don't auto-refresh, we receive values via props
|
||||
})
|
||||
|
||||
const updateUsageLimitMutation = useUpdateUsageLimit()
|
||||
const isUpdating =
|
||||
context === 'organization' ? isOrgUpdating : updateUsageLimitMutation.isPending
|
||||
|
||||
const handleStartEdit = () => {
|
||||
if (!canEdit) return
|
||||
setIsEditing(true)
|
||||
setInputValue(currentLimit.toString())
|
||||
}
|
||||
|
||||
// Expose startEdit method through ref
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
@@ -68,7 +71,6 @@ export const UsageLimit = forwardRef<UsageLimitRef, UsageLimitProps>(
|
||||
setInputValue(currentLimit.toString())
|
||||
}, [currentLimit])
|
||||
|
||||
// Focus input when entering edit mode
|
||||
useEffect(() => {
|
||||
if (isEditing && inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
@@ -76,7 +78,6 @@ export const UsageLimit = forwardRef<UsageLimitRef, UsageLimitProps>(
|
||||
}
|
||||
}, [isEditing])
|
||||
|
||||
// Clear error after 2 seconds
|
||||
useEffect(() => {
|
||||
if (hasError) {
|
||||
const timer = setTimeout(() => {
|
||||
@@ -96,11 +97,9 @@ export const UsageLimit = forwardRef<UsageLimitRef, UsageLimitProps>(
|
||||
return
|
||||
}
|
||||
|
||||
// Check if new limit is below current usage
|
||||
if (newLimit < currentUsage) {
|
||||
setHasError(true)
|
||||
setErrorType('belowUsage')
|
||||
// Don't reset input value - let user see what they typed
|
||||
return
|
||||
}
|
||||
|
||||
@@ -109,20 +108,43 @@ export const UsageLimit = forwardRef<UsageLimitRef, UsageLimitProps>(
|
||||
return
|
||||
}
|
||||
|
||||
// Use the centralized hook to update the limit
|
||||
const result = await updateLimit(newLimit)
|
||||
try {
|
||||
if (context === 'organization') {
|
||||
const result = await updateLimit(newLimit)
|
||||
|
||||
if (result.success) {
|
||||
setInputValue(newLimit.toString())
|
||||
onLimitUpdated?.(newLimit)
|
||||
setIsEditing(false)
|
||||
setErrorType(null)
|
||||
setHasError(false)
|
||||
} else {
|
||||
logger.error('Failed to update usage limit', { error: result.error })
|
||||
|
||||
if (result.error?.includes('below current usage')) {
|
||||
setErrorType('belowUsage')
|
||||
} else {
|
||||
setErrorType('general')
|
||||
}
|
||||
|
||||
setHasError(true)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await updateUsageLimitMutation.mutateAsync({ limit: newLimit })
|
||||
|
||||
if (result.success) {
|
||||
setInputValue(newLimit.toString())
|
||||
onLimitUpdated?.(newLimit)
|
||||
setIsEditing(false)
|
||||
setErrorType(null)
|
||||
setHasError(false)
|
||||
} else {
|
||||
logger.error('Failed to update usage limit', { error: result.error })
|
||||
} catch (err) {
|
||||
logger.error('Failed to update usage limit', { error: err })
|
||||
|
||||
// Check if the error is about being below current usage
|
||||
if (result.error?.includes('below current usage')) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
if (message.includes('below current usage')) {
|
||||
setErrorType('belowUsage')
|
||||
} else {
|
||||
setErrorType('general')
|
||||
@@ -161,7 +183,6 @@ export const UsageLimit = forwardRef<UsageLimitRef, UsageLimitProps>(
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={(e) => {
|
||||
// Don't submit if clicking on the button (it will handle submission)
|
||||
const relatedTarget = e.relatedTarget as HTMLElement
|
||||
if (relatedTarget?.closest('button')) {
|
||||
return
|
||||
|
||||
@@ -169,7 +169,6 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
const canManageWorkspaceKeys = userPermissions.canAdmin
|
||||
const logger = createLogger('Subscription')
|
||||
|
||||
// React Query hooks for data fetching
|
||||
const { data: subscriptionData, isLoading: isSubscriptionLoading } = useSubscriptionData()
|
||||
const { data: usageLimitResponse, isLoading: isUsageLimitLoading } = useUsageLimitData()
|
||||
const { data: workspaceData, isLoading: isWorkspaceLoading } = useWorkspaceSettings(workspaceId)
|
||||
@@ -179,7 +178,6 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
const activeOrganization = orgsData?.activeOrganization
|
||||
const activeOrgId = activeOrganization?.id
|
||||
|
||||
// Fetch organization billing data with React Query
|
||||
const { data: organizationBillingData, isLoading: isOrgBillingLoading } = useOrganizationBilling(
|
||||
activeOrgId || ''
|
||||
)
|
||||
@@ -187,10 +185,8 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
const [upgradeError, setUpgradeError] = useState<'pro' | 'team' | null>(null)
|
||||
const usageLimitRef = useRef<UsageLimitRef | null>(null)
|
||||
|
||||
// Combine all loading states
|
||||
const isLoading = isSubscriptionLoading || isUsageLimitLoading || isWorkspaceLoading
|
||||
|
||||
// Extract subscription status from subscriptionData.data
|
||||
const subscription = {
|
||||
isFree: subscriptionData?.data?.plan === 'free' || !subscriptionData?.data?.plan,
|
||||
isPro: subscriptionData?.data?.plan === 'pro',
|
||||
@@ -205,28 +201,23 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
seats: subscriptionData?.data?.seats || 1,
|
||||
}
|
||||
|
||||
// Extract usage data from subscriptionData.data.usage (same source as panel usage indicator)
|
||||
const usage = {
|
||||
current: subscriptionData?.data?.usage?.current || 0,
|
||||
limit: subscriptionData?.data?.usage?.limit || 0,
|
||||
percentUsed: subscriptionData?.data?.usage?.percentUsed || 0,
|
||||
}
|
||||
|
||||
// Extract usage limit metadata from usageLimitResponse.data
|
||||
const usageLimitData = {
|
||||
currentLimit: usageLimitResponse?.data?.currentLimit || 0,
|
||||
minimumLimit: usageLimitResponse?.data?.minimumLimit || (subscription.isPro ? 20 : 40),
|
||||
}
|
||||
|
||||
// Extract billing status
|
||||
const billingStatus = subscriptionData?.data?.billingBlocked ? 'blocked' : 'ok'
|
||||
|
||||
// Extract workspace settings
|
||||
const billedAccountUserId = workspaceData?.settings?.workspace?.billedAccountUserId ?? null
|
||||
const workspaceAdmins =
|
||||
workspaceData?.permissions?.users?.filter((user: any) => user.permissionType === 'admin') || []
|
||||
|
||||
// Update workspace settings handler
|
||||
const updateWorkspaceSettings = async (updates: { billedAccountUserId?: string }) => {
|
||||
if (!workspaceId) return
|
||||
try {
|
||||
@@ -240,7 +231,6 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-clear upgrade error
|
||||
useEffect(() => {
|
||||
if (upgradeError) {
|
||||
const timer = setTimeout(() => {
|
||||
@@ -250,11 +240,9 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
}
|
||||
}, [upgradeError])
|
||||
|
||||
// User role and permissions
|
||||
const userRole = getUserRole(activeOrganization, session?.user?.email)
|
||||
const isTeamAdmin = ['owner', 'admin'].includes(userRole)
|
||||
|
||||
// Get permissions based on subscription state and user role
|
||||
const permissions = getSubscriptionPermissions(
|
||||
{
|
||||
isFree: subscription.isFree,
|
||||
@@ -271,7 +259,6 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
}
|
||||
)
|
||||
|
||||
// Get visible plans based on current subscription
|
||||
const visiblePlans = getVisiblePlans(
|
||||
{
|
||||
isFree: subscription.isFree,
|
||||
@@ -459,8 +446,8 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
}
|
||||
context={subscription.isTeam && isTeamAdmin ? 'organization' : 'user'}
|
||||
organizationId={subscription.isTeam && isTeamAdmin ? activeOrgId : undefined}
|
||||
onLimitUpdated={async () => {
|
||||
// React Query will automatically refetch when the mutation completes
|
||||
onLimitUpdated={() => {
|
||||
logger.info('Usage limit updated')
|
||||
}}
|
||||
/>
|
||||
) : undefined
|
||||
@@ -469,6 +456,15 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Enterprise Usage Limit Notice */}
|
||||
{subscription.isEnterprise && (
|
||||
<div className='text-center'>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Contact enterprise for support usage limit changes
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cost Breakdown */}
|
||||
{/* TODO: Re-enable CostBreakdown component in the next billing period
|
||||
once sufficient copilot cost data has been collected for accurate display.
|
||||
@@ -554,14 +550,6 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
{/* Billing usage notifications toggle */}
|
||||
{subscription.isPaid && <BillingUsageNotificationsToggle />}
|
||||
|
||||
{subscription.isEnterprise && (
|
||||
<div className='text-center'>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Contact enterprise for support usage limit changes
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cancel Subscription */}
|
||||
{permissions.canCancelSubscription && (
|
||||
<div className='mt-2'>
|
||||
@@ -631,9 +619,6 @@ function BillingUsageNotificationsToggle() {
|
||||
const updateSetting = useUpdateGeneralSetting()
|
||||
const isLoading = updateSetting.isPending
|
||||
|
||||
// Settings are automatically loaded by SettingsLoader provider
|
||||
// No need to load here - Zustand is synced from React Query
|
||||
|
||||
return (
|
||||
<div className='mt-4 flex items-center justify-between'>
|
||||
<div className='flex flex-col'>
|
||||
|
||||
@@ -10,36 +10,46 @@ 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'
|
||||
|
||||
const logger = createLogger('UsageIndicator')
|
||||
|
||||
/**
|
||||
* Minimum number of pills to display (at minimum sidebar width)
|
||||
* Minimum number of pills to display (at minimum sidebar width).
|
||||
*/
|
||||
const MIN_PILL_COUNT = 6
|
||||
|
||||
/**
|
||||
* Maximum number of pills to display
|
||||
* Maximum number of pills to display.
|
||||
*/
|
||||
const MAX_PILL_COUNT = 8
|
||||
|
||||
/**
|
||||
* Width increase (in pixels) required to add one additional pill
|
||||
* Width increase (in pixels) required to add one additional pill.
|
||||
*/
|
||||
const WIDTH_PER_PILL = 50
|
||||
|
||||
/**
|
||||
* Animation configuration for usage pills
|
||||
* Controls how smoothly and quickly the highlight progresses across pills
|
||||
* Animation tick interval in milliseconds.
|
||||
* Controls the update frequency of the wave animation.
|
||||
*/
|
||||
const PILL_ANIMATION_TICK_MS = 30
|
||||
|
||||
/**
|
||||
* Speed of the wave animation in pills per second.
|
||||
*/
|
||||
const PILLS_PER_SECOND = 1.8
|
||||
|
||||
/**
|
||||
* Distance (in pill units) the wave advances per animation tick.
|
||||
* Derived from {@link PILLS_PER_SECOND} and {@link PILL_ANIMATION_TICK_MS}.
|
||||
*/
|
||||
const PILL_STEP_PER_TICK = (PILLS_PER_SECOND * PILL_ANIMATION_TICK_MS) / 1000
|
||||
|
||||
/**
|
||||
* Plan name mapping
|
||||
* Human-readable plan name labels.
|
||||
*/
|
||||
const PLAN_NAMES = {
|
||||
enterprise: 'Enterprise',
|
||||
@@ -48,17 +58,37 @@ const PLAN_NAMES = {
|
||||
free: 'Free',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Props for the {@link UsageIndicator} component.
|
||||
*/
|
||||
interface UsageIndicatorProps {
|
||||
/**
|
||||
* Optional click handler. If provided, overrides the default behavior
|
||||
* of opening the settings modal to the subscription tab.
|
||||
*/
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a visual usage indicator showing current subscription usage
|
||||
* with an animated pill bar that responds to hover interactions.
|
||||
*
|
||||
* The component shows:
|
||||
* - Current plan type (Free, Pro, Team, Enterprise)
|
||||
* - Current usage vs. limit (e.g., $7.00 / $10.00)
|
||||
* - Visual pill bar representing usage percentage
|
||||
* - Upgrade button for free plans or when blocked
|
||||
*
|
||||
* @param props - Component props
|
||||
* @returns A usage indicator component with responsive pill visualization
|
||||
*/
|
||||
export function UsageIndicator({ onClick }: UsageIndicatorProps) {
|
||||
const { data: subscriptionData, isLoading } = useSubscriptionData()
|
||||
const sidebarWidth = useSidebarStore((state) => state.sidebarWidth)
|
||||
|
||||
/**
|
||||
* Calculate pill count based on sidebar width (6-8 pills dynamically)
|
||||
* This provides responsive feedback as the sidebar width changes
|
||||
* Calculate pill count based on sidebar width (6-8 pills dynamically).
|
||||
* This provides responsive feedback as the sidebar width changes.
|
||||
*/
|
||||
const pillCount = useMemo(() => {
|
||||
const widthDelta = sidebarWidth - MIN_SIDEBAR_WIDTH
|
||||
@@ -82,54 +112,57 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
|
||||
|
||||
const billingStatus = getBillingStatus(subscriptionData?.data)
|
||||
const isBlocked = billingStatus === 'blocked'
|
||||
const showUpgradeButton = planType === 'free' || isBlocked
|
||||
const showUpgradeButton =
|
||||
(planType === 'free' || isBlocked || progressPercentage >= 80) && planType !== 'enterprise'
|
||||
|
||||
/**
|
||||
* Calculate which pills should be filled based on usage percentage
|
||||
* Uses shared Math.ceil heuristic but with dynamic pill count (6-8)
|
||||
* This ensures consistent calculation logic while maintaining responsive pill count
|
||||
* Calculate which pills should be filled based on usage percentage.
|
||||
* 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)
|
||||
const [hasWrapped, setHasWrapped] = useState(false)
|
||||
|
||||
const startAnimationIndex = pillCount === 0 ? 0 : Math.min(filledPillsCount, pillCount - 1)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isHovered || pillCount <= 0) {
|
||||
const isFreePlan = subscription.isFree
|
||||
|
||||
if (!isHovered || pillCount <= 0 || !isFreePlan) {
|
||||
setWavePosition(null)
|
||||
setHasWrapped(false)
|
||||
return
|
||||
}
|
||||
|
||||
const totalSpan = pillCount
|
||||
let wrapped = false
|
||||
setHasWrapped(false)
|
||||
/**
|
||||
* Maximum distance (in pill units) the wave should travel from
|
||||
* {@link startAnimationIndex} to the end of the row. The wave stops
|
||||
* once it reaches the final pill and does not wrap.
|
||||
*/
|
||||
const maxDistance = pillCount <= 0 ? 0 : Math.max(0, pillCount - startAnimationIndex)
|
||||
|
||||
setWavePosition(0)
|
||||
|
||||
const interval = window.setInterval(() => {
|
||||
setWavePosition((prev) => {
|
||||
const current = prev ?? 0
|
||||
const next = current + PILL_STEP_PER_TICK
|
||||
|
||||
// Mark as wrapped after first complete cycle
|
||||
if (next >= totalSpan && !wrapped) {
|
||||
wrapped = true
|
||||
setHasWrapped(true)
|
||||
if (current >= maxDistance) {
|
||||
return current
|
||||
}
|
||||
|
||||
// Return continuous value, never reset (seamless loop)
|
||||
return next
|
||||
const next = current + PILL_STEP_PER_TICK
|
||||
return next >= maxDistance ? maxDistance : next
|
||||
})
|
||||
}, PILL_ANIMATION_TICK_MS)
|
||||
|
||||
return () => {
|
||||
window.clearInterval(interval)
|
||||
}
|
||||
}, [isHovered, pillCount, startAnimationIndex])
|
||||
}, [isHovered, pillCount, startAnimationIndex, subscription.isFree])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -153,7 +186,7 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
|
||||
)
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
const handleClick = async () => {
|
||||
try {
|
||||
if (onClick) {
|
||||
onClick()
|
||||
@@ -163,7 +196,35 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
|
||||
const blocked = getBillingStatus(subscriptionData?.data) === 'blocked'
|
||||
const canUpg = canUpgrade(subscriptionData?.data)
|
||||
|
||||
// Open Settings modal to the subscription tab (upgrade UI lives there)
|
||||
// If blocked, try to open billing portal directly for faster recovery
|
||||
if (blocked) {
|
||||
try {
|
||||
const context = subscription.isTeam || subscription.isEnterprise ? 'organization' : 'user'
|
||||
const organizationId =
|
||||
subscription.isTeam || subscription.isEnterprise
|
||||
? subscriptionData?.data?.organization?.id
|
||||
: undefined
|
||||
|
||||
const response = await fetch('/api/billing/portal', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ context, organizationId }),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const { url } = await response.json()
|
||||
window.open(url, '_blank')
|
||||
logger.info('Opened billing portal for blocked account', { context, organizationId })
|
||||
return
|
||||
}
|
||||
} catch (portalError) {
|
||||
logger.warn('Failed to open billing portal, falling back to settings', {
|
||||
error: portalError,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Open Settings modal to the subscription tab
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'subscription' } }))
|
||||
logger.info('Opened settings to subscription tab', { blocked, canUpgrade: canUpg })
|
||||
@@ -175,7 +236,9 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className='group flex flex-shrink-0 cursor-pointer flex-col gap-[8px] border-t px-[13.5px] pt-[8px] pb-[10px]'
|
||||
className={`group flex flex-shrink-0 cursor-pointer flex-col gap-[8px] border-t px-[13.5px] pt-[8px] pb-[10px] ${
|
||||
isBlocked ? 'border-red-500/50 bg-red-950/20' : ''
|
||||
}`}
|
||||
onClick={handleClick}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
@@ -188,8 +251,8 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
|
||||
<div className='flex items-center gap-[4px]'>
|
||||
{isBlocked ? (
|
||||
<>
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>Over</span>
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>limit</span>
|
||||
<span className='font-medium text-[12px] text-red-400'>Payment</span>
|
||||
<span className='font-medium text-[12px] text-red-400'>Required</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -207,10 +270,14 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
|
||||
{showUpgradeButton && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='-mx-1 !h-auto !px-1 !py-0 !text-[#F473B7] group-hover:!text-[#F789C4] mt-[-2px] transition-colors duration-100'
|
||||
className={`-mx-1 !h-auto !px-1 !py-0 mt-[-2px] transition-colors duration-100 ${
|
||||
isBlocked
|
||||
? '!text-red-400 group-hover:!text-red-300'
|
||||
: '!text-[#F473B7] group-hover:!text-[#F789C4]'
|
||||
}`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<span className='font-medium text-[12px]'>Upgrade</span>
|
||||
<span className='font-medium text-[12px]'>{isBlocked ? 'Fix Now' : 'Upgrade'}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -220,63 +287,42 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
|
||||
{Array.from({ length: pillCount }).map((_, i) => {
|
||||
const isFilled = i < filledPillsCount
|
||||
|
||||
const baseColor = isFilled ? (isAlmostOut ? '#ef4444' : '#34B5FF') : '#414141'
|
||||
const baseColor = isFilled
|
||||
? 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) {
|
||||
const totalSpan = pillCount
|
||||
const grayColor = '#414141'
|
||||
const activeColor = isAlmostOut ? '#ef4444' : '#34B5FF'
|
||||
if (isHovered && wavePosition !== null && pillCount > 0 && subscription.isFree) {
|
||||
const grayColor = USAGE_PILL_COLORS.UNFILLED
|
||||
const activeColor = isAtLimit ? USAGE_PILL_COLORS.AT_LIMIT : USAGE_PILL_COLORS.FILLED
|
||||
|
||||
if (!hasWrapped) {
|
||||
// First pass: respect original fill state, start from startAnimationIndex
|
||||
const headIndex = Math.floor(wavePosition)
|
||||
const progress = wavePosition - headIndex
|
||||
/**
|
||||
* Single-pass wave: travel from {@link startAnimationIndex} to the end
|
||||
* of the row without wrapping. Previously highlighted pills remain
|
||||
* filled; the wave only affects pills at or after the start index.
|
||||
*/
|
||||
const headIndex = Math.floor(wavePosition)
|
||||
const progress = wavePosition - headIndex
|
||||
|
||||
const pillOffsetFromStart =
|
||||
i >= startAnimationIndex
|
||||
? i - startAnimationIndex
|
||||
: totalSpan - startAnimationIndex + i
|
||||
const pillOffsetFromStart = i - startAnimationIndex
|
||||
|
||||
if (pillOffsetFromStart < headIndex) {
|
||||
backgroundColor = baseColor
|
||||
backgroundImage = `linear-gradient(to right, ${activeColor} 0%, ${activeColor} 100%)`
|
||||
} else if (pillOffsetFromStart === headIndex) {
|
||||
const fillPercent = Math.max(0, Math.min(1, progress)) * 100
|
||||
backgroundColor = baseColor
|
||||
backgroundImage = `linear-gradient(to right, ${activeColor} 0%, ${activeColor} ${fillPercent}%, ${baseColor} ${fillPercent}%, ${baseColor} 100%)`
|
||||
}
|
||||
if (pillOffsetFromStart < 0) {
|
||||
// Before the wave start; keep original baseColor.
|
||||
} else if (pillOffsetFromStart < headIndex) {
|
||||
backgroundColor = isFilled ? baseColor : grayColor
|
||||
backgroundImage = `linear-gradient(to right, ${activeColor} 0%, ${activeColor} 100%)`
|
||||
} else if (pillOffsetFromStart === headIndex) {
|
||||
const fillPercent = Math.max(0, Math.min(1, progress)) * 100
|
||||
backgroundColor = isFilled ? baseColor : grayColor
|
||||
backgroundImage = `linear-gradient(to right, ${activeColor} 0%, ${activeColor} ${fillPercent}%, ${
|
||||
isFilled ? baseColor : grayColor
|
||||
} ${fillPercent}%, ${isFilled ? baseColor : grayColor} 100%)`
|
||||
} else {
|
||||
// Subsequent passes: render wave at BOTH current and next-cycle positions for seamless wrap
|
||||
const wrappedPosition = wavePosition % totalSpan
|
||||
const currentHead = Math.floor(wrappedPosition)
|
||||
const progress = wrappedPosition - currentHead
|
||||
|
||||
// Primary wave position
|
||||
const primaryFilled = i < currentHead
|
||||
const primaryActive = i === currentHead
|
||||
|
||||
// Secondary wave position (one full cycle ahead, wraps to beginning)
|
||||
const secondaryHead = Math.floor(wavePosition + totalSpan) % totalSpan
|
||||
const secondaryProgress =
|
||||
wavePosition + totalSpan - Math.floor(wavePosition + totalSpan)
|
||||
const secondaryFilled = i < secondaryHead
|
||||
const secondaryActive = i === secondaryHead
|
||||
|
||||
// Render: pill is filled if either wave position has filled it
|
||||
if (primaryFilled || secondaryFilled) {
|
||||
backgroundColor = grayColor
|
||||
backgroundImage = `linear-gradient(to right, ${activeColor} 0%, ${activeColor} 100%)`
|
||||
} else if (primaryActive || secondaryActive) {
|
||||
const activeProgress = primaryActive ? progress : secondaryProgress
|
||||
const fillPercent = Math.max(0, Math.min(1, activeProgress)) * 100
|
||||
backgroundColor = grayColor
|
||||
backgroundImage = `linear-gradient(to right, ${activeColor} 0%, ${activeColor} ${fillPercent}%, ${grayColor} ${fillPercent}%, ${grayColor} 100%)`
|
||||
} else {
|
||||
backgroundColor = grayColor
|
||||
}
|
||||
backgroundColor = isFilled ? baseColor : grayColor
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ import {
|
||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||
import { useDeleteFolder, useDuplicateFolder } from '@/app/workspace/[workspaceId]/w/hooks'
|
||||
import { useUpdateFolder } from '@/hooks/queries/folders'
|
||||
import { useCreateWorkflow } from '@/hooks/queries/workflows'
|
||||
import type { FolderTreeNode } from '@/stores/folders/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
interface FolderItemProps {
|
||||
folder: FolderTreeNode
|
||||
@@ -39,7 +39,7 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
|
||||
const router = useRouter()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const updateFolderMutation = useUpdateFolder()
|
||||
const { createWorkflow } = useWorkflowRegistry()
|
||||
const createWorkflowMutation = useCreateWorkflow()
|
||||
|
||||
// Delete modal state
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
|
||||
@@ -58,18 +58,18 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
|
||||
})
|
||||
|
||||
/**
|
||||
* Handle create workflow in folder
|
||||
* Handle create workflow in folder using React Query mutation
|
||||
*/
|
||||
const handleCreateWorkflowInFolder = useCallback(async () => {
|
||||
const workflowId = await createWorkflow({
|
||||
const result = await createWorkflowMutation.mutateAsync({
|
||||
workspaceId,
|
||||
folderId: folder.id,
|
||||
})
|
||||
|
||||
if (workflowId) {
|
||||
router.push(`/workspace/${workspaceId}/w/${workflowId}`)
|
||||
if (result.id) {
|
||||
router.push(`/workspace/${workspaceId}/w/${result.id}`)
|
||||
}
|
||||
}, [createWorkflow, workspaceId, folder.id, router])
|
||||
}, [createWorkflowMutation, workspaceId, folder.id, router])
|
||||
|
||||
// Folder expand hook
|
||||
const {
|
||||
|
||||
@@ -6,18 +6,18 @@ import { useParams, useRouter } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { generateFolderName } from '@/lib/naming'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
extractWorkflowName,
|
||||
extractWorkflowsFromFiles,
|
||||
extractWorkflowsFromZip,
|
||||
} from '@/lib/workflows/import-export'
|
||||
import { generateFolderName } from '@/lib/workspaces/naming'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { useCreateFolder } from '@/hooks/queries/folders'
|
||||
import { useCreateWorkflow } from '@/hooks/queries/workflows'
|
||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
|
||||
import { parseWorkflowJson } from '@/stores/workflows/json/importer'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
const logger = createLogger('CreateMenu')
|
||||
|
||||
@@ -44,7 +44,7 @@ export function CreateMenu({ onCreateWorkflow, isCreatingWorkflow = false }: Cre
|
||||
const router = useRouter()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const createFolderMutation = useCreateFolder()
|
||||
const { createWorkflow } = useWorkflowRegistry()
|
||||
const createWorkflowMutation = useCreateWorkflow()
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
@@ -194,12 +194,13 @@ export function CreateMenu({ onCreateWorkflow, isCreatingWorkflow = false }: Cre
|
||||
const { clearDiff } = useWorkflowDiffStore.getState()
|
||||
clearDiff()
|
||||
|
||||
const newWorkflowId = await createWorkflow({
|
||||
const result = await createWorkflowMutation.mutateAsync({
|
||||
name: workflowName,
|
||||
description: 'Imported from workspace export',
|
||||
workspaceId,
|
||||
folderId: targetFolderId,
|
||||
})
|
||||
const newWorkflowId = result.id
|
||||
|
||||
const response = await fetch(`/api/workflows/${newWorkflowId}/state`, {
|
||||
method: 'PUT',
|
||||
@@ -255,11 +256,12 @@ export function CreateMenu({ onCreateWorkflow, isCreatingWorkflow = false }: Cre
|
||||
const { clearDiff } = useWorkflowDiffStore.getState()
|
||||
clearDiff()
|
||||
|
||||
const newWorkflowId = await createWorkflow({
|
||||
const result = await createWorkflowMutation.mutateAsync({
|
||||
name: workflowName,
|
||||
description: 'Imported from JSON',
|
||||
workspaceId,
|
||||
})
|
||||
const newWorkflowId = result.id
|
||||
|
||||
const response = await fetch(`/api/workflows/${newWorkflowId}/state`, {
|
||||
method: 'PUT',
|
||||
@@ -299,8 +301,8 @@ export function CreateMenu({ onCreateWorkflow, isCreatingWorkflow = false }: Cre
|
||||
}
|
||||
}
|
||||
|
||||
const { loadWorkflows } = useWorkflowRegistry.getState()
|
||||
await loadWorkflows(workspaceId)
|
||||
// Invalidate workflow queries to reload the list
|
||||
// The useWorkflows hook in the sidebar will automatically refetch
|
||||
} catch (error) {
|
||||
logger.error('Failed to import workflows:', error)
|
||||
} finally {
|
||||
@@ -310,7 +312,7 @@ export function CreateMenu({ onCreateWorkflow, isCreatingWorkflow = false }: Cre
|
||||
}
|
||||
}
|
||||
},
|
||||
[workspaceId, createWorkflow, createFolderMutation]
|
||||
[workspaceId, createWorkflowMutation, createFolderMutation]
|
||||
)
|
||||
|
||||
// Button event handlers
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user