mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
fix(deploy-modal): break down deploy modal into separate components (#837)
Co-authored-by: waleedlatif <waleedlatif@waleedlatifs-MacBook-Pro.local>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,268 @@
|
||||
import { useState } from 'react'
|
||||
import { Check, Copy, Eye, EyeOff, Plus, RefreshCw, Trash2 } from 'lucide-react'
|
||||
import { Button, Card, CardContent, Input, Label } from '@/components/ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { AuthType } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/hooks/use-chat-form'
|
||||
|
||||
interface AuthSelectorProps {
|
||||
authType: AuthType
|
||||
password: string
|
||||
emails: string[]
|
||||
onAuthTypeChange: (type: AuthType) => void
|
||||
onPasswordChange: (password: string) => void
|
||||
onEmailsChange: (emails: string[]) => void
|
||||
disabled?: boolean
|
||||
isExistingChat?: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export function AuthSelector({
|
||||
authType,
|
||||
password,
|
||||
emails,
|
||||
onAuthTypeChange,
|
||||
onPasswordChange,
|
||||
onEmailsChange,
|
||||
disabled = false,
|
||||
isExistingChat = false,
|
||||
error,
|
||||
}: AuthSelectorProps) {
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [newEmail, setNewEmail] = useState('')
|
||||
const [emailError, setEmailError] = useState('')
|
||||
const [copySuccess, setCopySuccess] = useState(false)
|
||||
|
||||
const generatePassword = () => {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_-+='
|
||||
let result = ''
|
||||
const length = 24
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
|
||||
onPasswordChange(result)
|
||||
}
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
setCopySuccess(true)
|
||||
setTimeout(() => setCopySuccess(false), 2000)
|
||||
}
|
||||
|
||||
const handleAddEmail = () => {
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newEmail) && !newEmail.startsWith('@')) {
|
||||
setEmailError('Please enter a valid email or domain (e.g., user@example.com or @example.com)')
|
||||
return
|
||||
}
|
||||
|
||||
if (emails.includes(newEmail)) {
|
||||
setEmailError('This email or domain is already in the list')
|
||||
return
|
||||
}
|
||||
|
||||
onEmailsChange([...emails, newEmail])
|
||||
setNewEmail('')
|
||||
setEmailError('')
|
||||
}
|
||||
|
||||
const handleRemoveEmail = (email: string) => {
|
||||
onEmailsChange(emails.filter((e) => e !== email))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-2'>
|
||||
<Label className='font-medium text-sm'>Access Control</Label>
|
||||
|
||||
{/* Auth Type Selection */}
|
||||
<div className='grid grid-cols-1 gap-3 md:grid-cols-3'>
|
||||
{(['public', 'password', 'email'] as const).map((type) => (
|
||||
<Card
|
||||
key={type}
|
||||
className={cn(
|
||||
'cursor-pointer overflow-hidden shadow-none transition-colors hover:bg-accent/30',
|
||||
authType === type
|
||||
? 'border border-muted-foreground hover:bg-accent/50'
|
||||
: 'border border-input'
|
||||
)}
|
||||
>
|
||||
<CardContent className='relative flex flex-col items-center justify-center p-4 text-center'>
|
||||
<button
|
||||
type='button'
|
||||
className='absolute inset-0 z-10 h-full w-full cursor-pointer'
|
||||
onClick={() => !disabled && onAuthTypeChange(type)}
|
||||
aria-label={`Select ${type} access`}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<div className='justify-center text-center align-middle'>
|
||||
<h3 className='font-medium text-sm'>
|
||||
{type === 'public' && 'Public Access'}
|
||||
{type === 'password' && 'Password Protected'}
|
||||
{type === 'email' && 'Email Access'}
|
||||
</h3>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{type === 'public' && 'Anyone can access your chat'}
|
||||
{type === 'password' && 'Secure with a single password'}
|
||||
{type === 'email' && 'Restrict to specific emails'}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Auth Settings */}
|
||||
{authType === 'password' && (
|
||||
<Card className='shadow-none'>
|
||||
<CardContent className='p-4'>
|
||||
<h3 className='mb-2 font-medium text-sm'>Password Settings</h3>
|
||||
|
||||
{isExistingChat && !password && (
|
||||
<div className='mb-2 flex items-center text-muted-foreground text-xs'>
|
||||
<div className='mr-2 rounded-full bg-primary/10 px-2 py-0.5 font-medium text-primary'>
|
||||
Password set
|
||||
</div>
|
||||
<span>Current password is securely stored</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='relative'>
|
||||
<Input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder={
|
||||
isExistingChat
|
||||
? 'Enter new password (leave empty to keep current)'
|
||||
: 'Enter password'
|
||||
}
|
||||
value={password}
|
||||
onChange={(e) => onPasswordChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
className='pr-28'
|
||||
required={!isExistingChat}
|
||||
/>
|
||||
<div className='absolute top-0 right-0 flex h-full'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={generatePassword}
|
||||
disabled={disabled}
|
||||
className='px-2'
|
||||
>
|
||||
<RefreshCw className='h-4 w-4' />
|
||||
<span className='sr-only'>Generate password</span>
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={() => copyToClipboard(password)}
|
||||
disabled={!password || disabled}
|
||||
className='px-2'
|
||||
>
|
||||
{copySuccess ? <Check className='h-4 w-4' /> : <Copy className='h-4 w-4' />}
|
||||
<span className='sr-only'>Copy password</span>
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
disabled={disabled}
|
||||
className='px-2'
|
||||
>
|
||||
{showPassword ? <EyeOff className='h-4 w-4' /> : <Eye className='h-4 w-4' />}
|
||||
<span className='sr-only'>
|
||||
{showPassword ? 'Hide password' : 'Show password'}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className='mt-2 text-muted-foreground text-xs'>
|
||||
{isExistingChat
|
||||
? 'Leaving this empty will keep the current password. Enter a new password to change it.'
|
||||
: 'This password will be required to access your chat.'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{authType === 'email' && (
|
||||
<Card className='shadow-none'>
|
||||
<CardContent className='p-4'>
|
||||
<h3 className='mb-2 font-medium text-sm'>Email Access Settings</h3>
|
||||
|
||||
<div className='flex gap-2'>
|
||||
<Input
|
||||
placeholder='user@example.com or @domain.com'
|
||||
value={newEmail}
|
||||
onChange={(e) => setNewEmail(e.target.value)}
|
||||
disabled={disabled}
|
||||
className='flex-1'
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleAddEmail()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type='button'
|
||||
onClick={handleAddEmail}
|
||||
disabled={!newEmail.trim() || disabled}
|
||||
className='shrink-0'
|
||||
>
|
||||
<Plus className='h-4 w-4' />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{emailError && <p className='mt-1 text-destructive text-sm'>{emailError}</p>}
|
||||
|
||||
{emails.length > 0 && (
|
||||
<div className='mt-3 max-h-[150px] overflow-y-auto rounded-md border bg-background px-2 py-0 shadow-none'>
|
||||
<ul className='divide-y divide-border'>
|
||||
{emails.map((email) => (
|
||||
<li key={email} className='relative'>
|
||||
<div className='group my-1 flex items-center justify-between rounded-sm px-2 py-2 text-sm'>
|
||||
<span className='font-medium text-foreground'>{email}</span>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={() => handleRemoveEmail(email)}
|
||||
disabled={disabled}
|
||||
className='h-7 w-7 opacity-70'
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className='mt-2 text-muted-foreground text-xs'>
|
||||
Add specific emails or entire domains (@example.com)
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{authType === 'public' && (
|
||||
<Card className='shadow-none'>
|
||||
<CardContent className='p-4'>
|
||||
<h3 className='mb-2 font-medium text-sm'>Public Access Settings</h3>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
This chat will be publicly accessible to anyone with the link.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{error && <p className='text-destructive text-sm'>{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useEffect } from 'react'
|
||||
import { Input, Label } from '@/components/ui'
|
||||
import { getEmailDomain } from '@/lib/urls/utils'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSubdomainValidation } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/hooks/use-subdomain-validation'
|
||||
|
||||
interface SubdomainInputProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
originalSubdomain?: string
|
||||
disabled?: boolean
|
||||
onValidationChange?: (isValid: boolean) => void
|
||||
}
|
||||
|
||||
const getDomainSuffix = (() => {
|
||||
const suffix = `.${getEmailDomain()}`
|
||||
return () => suffix
|
||||
})()
|
||||
|
||||
export function SubdomainInput({
|
||||
value,
|
||||
onChange,
|
||||
originalSubdomain,
|
||||
disabled = false,
|
||||
onValidationChange,
|
||||
}: SubdomainInputProps) {
|
||||
const { isChecking, error, isValid } = useSubdomainValidation(value, originalSubdomain)
|
||||
|
||||
// Notify parent of validation changes
|
||||
useEffect(() => {
|
||||
onValidationChange?.(isValid)
|
||||
}, [isValid, onValidationChange])
|
||||
|
||||
const handleChange = (newValue: string) => {
|
||||
const lowercaseValue = newValue.toLowerCase()
|
||||
onChange(lowercaseValue)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='subdomain' className='font-medium text-sm'>
|
||||
Subdomain
|
||||
</Label>
|
||||
<div className='relative flex items-center rounded-md ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2'>
|
||||
<Input
|
||||
id='subdomain'
|
||||
placeholder='company-name'
|
||||
value={value}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
required
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'rounded-r-none border-r-0 focus-visible:ring-0 focus-visible:ring-offset-0',
|
||||
error && 'border-destructive focus-visible:border-destructive'
|
||||
)}
|
||||
/>
|
||||
<div className='flex h-10 items-center whitespace-nowrap rounded-r-md border border-l-0 bg-muted px-3 font-medium text-muted-foreground text-sm'>
|
||||
{getDomainSuffix()}
|
||||
</div>
|
||||
{isChecking && (
|
||||
<div className='absolute right-14 flex items-center'>
|
||||
<div className='h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-blue-600' />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{error && <p className='mt-1 text-destructive text-sm'>{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { z } from 'zod'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { ChatFormData } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/hooks/use-chat-form'
|
||||
import type { OutputConfig } from '@/stores/panel/chat/types'
|
||||
|
||||
const logger = createLogger('ChatDeployment')
|
||||
|
||||
export interface ChatDeploymentState {
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
deployedUrl: string | null
|
||||
}
|
||||
|
||||
const chatSchema = z.object({
|
||||
workflowId: z.string().min(1, 'Workflow ID is required'),
|
||||
subdomain: z
|
||||
.string()
|
||||
.min(1, 'Subdomain is required')
|
||||
.regex(/^[a-z0-9-]+$/, 'Subdomain can only contain lowercase letters, numbers, and hyphens'),
|
||||
title: z.string().min(1, 'Title is required'),
|
||||
description: z.string().optional(),
|
||||
customizations: z.object({
|
||||
primaryColor: z.string(),
|
||||
welcomeMessage: z.string(),
|
||||
}),
|
||||
authType: z.enum(['public', 'password', 'email']).default('public'),
|
||||
password: z.string().optional(),
|
||||
allowedEmails: z.array(z.string()).optional().default([]),
|
||||
outputConfigs: z
|
||||
.array(
|
||||
z.object({
|
||||
blockId: z.string(),
|
||||
path: z.string(),
|
||||
})
|
||||
)
|
||||
.optional()
|
||||
.default([]),
|
||||
})
|
||||
|
||||
export function useChatDeployment() {
|
||||
const [state, setState] = useState<ChatDeploymentState>({
|
||||
isLoading: false,
|
||||
error: null,
|
||||
deployedUrl: null,
|
||||
})
|
||||
|
||||
const deployChat = useCallback(
|
||||
async (
|
||||
workflowId: string,
|
||||
formData: ChatFormData,
|
||||
deploymentInfo: { apiKey: string } | null,
|
||||
existingChatId?: string
|
||||
) => {
|
||||
setState({ isLoading: true, error: null, deployedUrl: null })
|
||||
|
||||
try {
|
||||
// Prepare output configs
|
||||
const outputConfigs: OutputConfig[] = formData.selectedOutputBlocks
|
||||
.map((outputId) => {
|
||||
const firstUnderscoreIndex = outputId.indexOf('_')
|
||||
if (firstUnderscoreIndex !== -1) {
|
||||
const blockId = outputId.substring(0, firstUnderscoreIndex)
|
||||
const path = outputId.substring(firstUnderscoreIndex + 1)
|
||||
if (blockId && path) {
|
||||
return { blockId, path }
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
.filter(Boolean) as OutputConfig[]
|
||||
|
||||
// Create request payload
|
||||
const payload = {
|
||||
workflowId,
|
||||
subdomain: formData.subdomain.trim(),
|
||||
title: formData.title.trim(),
|
||||
description: formData.description.trim(),
|
||||
customizations: {
|
||||
primaryColor: '#802FFF',
|
||||
welcomeMessage: formData.welcomeMessage.trim(),
|
||||
},
|
||||
authType: formData.authType,
|
||||
password: formData.authType === 'password' ? formData.password : undefined,
|
||||
allowedEmails: formData.authType === 'email' ? formData.emails : [],
|
||||
outputConfigs,
|
||||
apiKey: deploymentInfo?.apiKey,
|
||||
deployApiEnabled: !existingChatId, // Only deploy API for new chats
|
||||
}
|
||||
|
||||
// Validate with Zod
|
||||
chatSchema.parse(payload)
|
||||
|
||||
// Determine endpoint and method
|
||||
const endpoint = existingChatId ? `/api/chat/edit/${existingChatId}` : '/api/chat'
|
||||
const method = existingChatId ? 'PATCH' : 'POST'
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
// Handle subdomain conflict specifically
|
||||
if (result.error === 'Subdomain already in use') {
|
||||
throw new Error('This subdomain is already in use')
|
||||
}
|
||||
throw new Error(result.error || `Failed to ${existingChatId ? 'update' : 'deploy'} chat`)
|
||||
}
|
||||
|
||||
if (!result.chatUrl) {
|
||||
throw new Error('Response missing chatUrl')
|
||||
}
|
||||
|
||||
setState({
|
||||
isLoading: false,
|
||||
error: null,
|
||||
deployedUrl: result.chatUrl,
|
||||
})
|
||||
|
||||
logger.info(`Chat ${existingChatId ? 'updated' : 'deployed'} successfully:`, result.chatUrl)
|
||||
return result.chatUrl
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.message || 'An unexpected error occurred'
|
||||
setState({
|
||||
isLoading: false,
|
||||
error: errorMessage,
|
||||
deployedUrl: null,
|
||||
})
|
||||
|
||||
logger.error(`Failed to ${existingChatId ? 'update' : 'deploy'} chat:`, error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setState({
|
||||
isLoading: false,
|
||||
error: null,
|
||||
deployedUrl: null,
|
||||
})
|
||||
}, [])
|
||||
|
||||
return {
|
||||
...state,
|
||||
deployChat,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
|
||||
export type AuthType = 'public' | 'password' | 'email'
|
||||
|
||||
export interface ChatFormData {
|
||||
subdomain: string
|
||||
title: string
|
||||
description: string
|
||||
authType: AuthType
|
||||
password: string
|
||||
emails: string[]
|
||||
welcomeMessage: string
|
||||
selectedOutputBlocks: string[]
|
||||
}
|
||||
|
||||
export interface ChatFormErrors {
|
||||
subdomain?: string
|
||||
title?: string
|
||||
password?: string
|
||||
emails?: string
|
||||
outputBlocks?: string
|
||||
general?: string
|
||||
}
|
||||
|
||||
const initialFormData: ChatFormData = {
|
||||
subdomain: '',
|
||||
title: '',
|
||||
description: '',
|
||||
authType: 'public',
|
||||
password: '',
|
||||
emails: [],
|
||||
welcomeMessage: 'Hi there! How can I help you today?',
|
||||
selectedOutputBlocks: [],
|
||||
}
|
||||
|
||||
export function useChatForm(initialData?: Partial<ChatFormData>) {
|
||||
const [formData, setFormData] = useState<ChatFormData>({
|
||||
...initialFormData,
|
||||
...initialData,
|
||||
})
|
||||
|
||||
const [errors, setErrors] = useState<ChatFormErrors>({})
|
||||
|
||||
const updateField = useCallback(
|
||||
<K extends keyof ChatFormData>(field: K, value: ChatFormData[K]) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }))
|
||||
// Clear error when user starts typing
|
||||
if (field in errors && errors[field as keyof ChatFormErrors]) {
|
||||
setErrors((prev) => ({ ...prev, [field]: undefined }))
|
||||
}
|
||||
},
|
||||
[errors]
|
||||
)
|
||||
|
||||
const setError = useCallback((field: keyof ChatFormErrors, message: string) => {
|
||||
setErrors((prev) => ({ ...prev, [field]: message }))
|
||||
}, [])
|
||||
|
||||
const clearError = useCallback((field: keyof ChatFormErrors) => {
|
||||
setErrors((prev) => ({ ...prev, [field]: undefined }))
|
||||
}, [])
|
||||
|
||||
const clearAllErrors = useCallback(() => {
|
||||
setErrors({})
|
||||
}, [])
|
||||
|
||||
const validateForm = useCallback((): boolean => {
|
||||
const newErrors: ChatFormErrors = {}
|
||||
|
||||
if (!formData.subdomain.trim()) {
|
||||
newErrors.subdomain = 'Subdomain is required'
|
||||
} else if (!/^[a-z0-9-]+$/.test(formData.subdomain)) {
|
||||
newErrors.subdomain = 'Subdomain can only contain lowercase letters, numbers, and hyphens'
|
||||
}
|
||||
|
||||
if (!formData.title.trim()) {
|
||||
newErrors.title = 'Title is required'
|
||||
}
|
||||
|
||||
if (formData.authType === 'password' && !formData.password.trim()) {
|
||||
newErrors.password = 'Password is required when using password protection'
|
||||
}
|
||||
|
||||
if (formData.authType === 'email' && formData.emails.length === 0) {
|
||||
newErrors.emails = 'At least one email or domain is required when using email access control'
|
||||
}
|
||||
|
||||
if (formData.selectedOutputBlocks.length === 0) {
|
||||
newErrors.outputBlocks = 'Please select at least one output block'
|
||||
}
|
||||
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}, [formData])
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setFormData(initialFormData)
|
||||
setErrors({})
|
||||
}, [])
|
||||
|
||||
return {
|
||||
formData,
|
||||
errors,
|
||||
updateField,
|
||||
setError,
|
||||
clearError,
|
||||
clearAllErrors,
|
||||
validateForm,
|
||||
resetForm,
|
||||
setFormData,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
export function useSubdomainValidation(subdomain: string, originalSubdomain?: string) {
|
||||
const [isChecking, setIsChecking] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isValid, setIsValid] = useState(false)
|
||||
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Clear previous timeout
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
}
|
||||
|
||||
// Reset states immediately when subdomain changes
|
||||
setError(null)
|
||||
setIsValid(false)
|
||||
setIsChecking(false)
|
||||
|
||||
// Skip validation if empty or same as original
|
||||
if (!subdomain.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (subdomain === originalSubdomain) {
|
||||
setIsValid(true)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate format first
|
||||
if (!/^[a-z0-9-]+$/.test(subdomain)) {
|
||||
setError('Subdomain can only contain lowercase letters, numbers, and hyphens')
|
||||
return
|
||||
}
|
||||
|
||||
// Debounce API call
|
||||
setIsChecking(true)
|
||||
timeoutRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/chat/subdomains/validate?subdomain=${encodeURIComponent(subdomain)}`
|
||||
)
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok || !data.available) {
|
||||
setError(data.error || 'This subdomain is already in use')
|
||||
setIsValid(false)
|
||||
} else {
|
||||
setError(null)
|
||||
setIsValid(true)
|
||||
}
|
||||
} catch (error) {
|
||||
setError('Error checking subdomain availability')
|
||||
setIsValid(false)
|
||||
} finally {
|
||||
setIsChecking(false)
|
||||
}
|
||||
}, 500)
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [subdomain, originalSubdomain])
|
||||
|
||||
return { isChecking, error, isValid }
|
||||
}
|
||||
@@ -1,30 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Info, Loader2, X } from 'lucide-react'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Loader2, X } from 'lucide-react'
|
||||
import { Button, Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui'
|
||||
import { getEnv } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
ChatDeploy,
|
||||
DeployForm,
|
||||
DeploymentInfo,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components'
|
||||
import { ChatDeploy } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/chat-deploy'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
@@ -93,12 +79,10 @@ export function DeployModal({
|
||||
const [apiKeys, setApiKeys] = useState<ApiKey[]>([])
|
||||
const [keysLoaded, setKeysLoaded] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState<TabView>('api')
|
||||
const [isChatDeploying, setIsChatDeploying] = useState(false)
|
||||
const [chatSubmitting, setChatSubmitting] = useState(false)
|
||||
const [apiDeployError, setApiDeployError] = useState<string | null>(null)
|
||||
const [chatExists, setChatExists] = useState(false)
|
||||
const [deployedChatUrl, setDeployedChatUrl] = useState<string | null>(null)
|
||||
const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false)
|
||||
const [isChatFormValid, setIsChatFormValid] = useState(false)
|
||||
|
||||
// Generate an example input format for the API request
|
||||
const getInputFormatExample = () => {
|
||||
@@ -173,20 +157,16 @@ export function DeployModal({
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
if (data.isDeployed && data.deployment && data.deployment.chatUrl) {
|
||||
setDeployedChatUrl(data.deployment.chatUrl)
|
||||
if (data.isDeployed && data.deployment) {
|
||||
setChatExists(true)
|
||||
} else {
|
||||
setDeployedChatUrl(null)
|
||||
setChatExists(false)
|
||||
}
|
||||
} else {
|
||||
setDeployedChatUrl(null)
|
||||
setChatExists(false)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching chat deployment info:', { error })
|
||||
setDeployedChatUrl(null)
|
||||
setChatExists(false)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
@@ -333,7 +313,6 @@ export function DeployModal({
|
||||
setDeploymentStatus(workflowId, false)
|
||||
|
||||
// Reset chat deployment info
|
||||
setDeployedChatUrl(null)
|
||||
setChatExists(false)
|
||||
|
||||
// Close the modal
|
||||
@@ -393,62 +372,18 @@ export function DeployModal({
|
||||
// Custom close handler to ensure we clean up loading states
|
||||
const handleCloseModal = () => {
|
||||
setIsSubmitting(false)
|
||||
setIsChatDeploying(false)
|
||||
setChatSubmitting(false)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
// Add a new handler for chat undeploy
|
||||
const handleChatUndeploy = async () => {
|
||||
try {
|
||||
setIsUndeploying(true)
|
||||
|
||||
// First get the chat deployment info
|
||||
const response = await fetch(`/api/workflows/${workflowId}/chat/status`)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to get chat info')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.isDeployed || !data.deployment || !data.deployment.id) {
|
||||
throw new Error('No active chat deployment found')
|
||||
}
|
||||
|
||||
// Delete the chat
|
||||
const deleteResponse = await fetch(`/api/chat/edit/${data.deployment.id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (!deleteResponse.ok) {
|
||||
const errorData = await deleteResponse.json()
|
||||
throw new Error(errorData.error || 'Failed to undeploy chat')
|
||||
}
|
||||
|
||||
// Reset chat deployment info
|
||||
setDeployedChatUrl(null)
|
||||
setChatExists(false)
|
||||
// Close the modal
|
||||
onOpenChange(false)
|
||||
} catch (error: any) {
|
||||
logger.error('Error undeploying chat:', { error })
|
||||
} finally {
|
||||
setIsUndeploying(false)
|
||||
setShowDeleteConfirmation(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Find or create appropriate method to handle chat deployment
|
||||
// Handle chat form submission
|
||||
const handleChatSubmit = async () => {
|
||||
// Check if workflow is deployed
|
||||
if (!isDeployed) {
|
||||
// Deploy workflow first
|
||||
try {
|
||||
setChatSubmitting(true)
|
||||
setChatSubmitting(true)
|
||||
|
||||
// Call the API to deploy the workflow
|
||||
try {
|
||||
// Check if workflow is deployed first
|
||||
if (!isDeployed) {
|
||||
// Deploy workflow first
|
||||
const response = await fetch(`/api/workflows/${workflowId}/deploy`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -474,58 +409,17 @@ export function DeployModal({
|
||||
deployedAt ? new Date(deployedAt) : undefined,
|
||||
apiKey
|
||||
)
|
||||
} catch (error: any) {
|
||||
logger.error('Error auto-deploying workflow for chat:', { error })
|
||||
setChatSubmitting(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Trigger form submission in the ChatDeploy component
|
||||
const form = document.querySelector('.chat-deploy-form') as HTMLFormElement
|
||||
if (form) {
|
||||
form.requestSubmit()
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error('Error auto-deploying workflow for chat:', { error })
|
||||
setChatSubmitting(false)
|
||||
}
|
||||
|
||||
// Now submit the chat deploy form
|
||||
const form = document.querySelector('.chat-deploy-form') as HTMLFormElement
|
||||
if (form) {
|
||||
form.requestSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
const handleChatDeploymentComplete = () => {
|
||||
setChatSubmitting(false)
|
||||
}
|
||||
|
||||
// Render deployed chat view
|
||||
const _renderDeployedChatView = () => {
|
||||
if (!deployedChatUrl) {
|
||||
return (
|
||||
<div className='flex items-center justify-center py-12 text-muted-foreground'>
|
||||
<div className='flex flex-col items-center gap-2'>
|
||||
<Info className='h-5 w-5' />
|
||||
<p className='text-sm'>No chat deployment information available</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<Card className='border-green-200 bg-green-50 dark:border-green-900 dark:bg-green-900/20'>
|
||||
<CardContent className='p-6 text-green-800 dark:text-green-400'>
|
||||
<h3 className='mb-2 font-medium text-base'>Chat Deployment Active</h3>
|
||||
<p className='mb-3'>Your chat is available at:</p>
|
||||
<div className='group relative rounded-md border border-green-200 bg-white/50 p-3 dark:border-green-900/50 dark:bg-gray-900/50'>
|
||||
<a
|
||||
href={deployedChatUrl}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='block break-all pr-8 font-medium text-primary text-sm underline'
|
||||
>
|
||||
{deployedChatUrl}
|
||||
</a>
|
||||
<CopyButton text={deployedChatUrl || ''} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -614,12 +508,11 @@ export function DeployModal({
|
||||
{activeTab === 'chat' && (
|
||||
<ChatDeploy
|
||||
workflowId={workflowId || ''}
|
||||
onClose={() => onOpenChange(false)}
|
||||
deploymentInfo={deploymentInfo}
|
||||
onChatExistsChange={setChatExists}
|
||||
showDeleteConfirmation={showDeleteConfirmation}
|
||||
setShowDeleteConfirmation={setShowDeleteConfirmation}
|
||||
onDeploymentComplete={handleChatDeploymentComplete}
|
||||
chatSubmitting={chatSubmitting}
|
||||
setChatSubmitting={setChatSubmitting}
|
||||
onValidationChange={setIsChatFormValid}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -636,7 +529,7 @@ export function DeployModal({
|
||||
<Button
|
||||
type='button'
|
||||
onClick={() => onDeploy({ apiKey: apiKeys.length > 0 ? apiKeys[0].key : '' })}
|
||||
disabled={isSubmitting || (!keysLoaded && !apiKeys.length) || isChatDeploying}
|
||||
disabled={isSubmitting || (!keysLoaded && !apiKeys.length)}
|
||||
className={cn(
|
||||
'gap-2 font-medium',
|
||||
'bg-[#802FFF] hover:bg-[#7028E6]',
|
||||
@@ -663,69 +556,29 @@ export function DeployModal({
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<div className='flex gap-2'>
|
||||
{chatExists && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant='destructive' disabled={chatSubmitting || isUndeploying}>
|
||||
{isUndeploying ? (
|
||||
<>
|
||||
<Loader2 className='mr-1.5 h-3.5 w-3.5 animate-spin' />
|
||||
Undeploying...
|
||||
</>
|
||||
) : (
|
||||
'Delete'
|
||||
)}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Chat</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete this chat? This will remove the chat
|
||||
interface and make it unavailable to external users.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleChatUndeploy}
|
||||
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<Button
|
||||
type='button'
|
||||
onClick={handleChatSubmit}
|
||||
disabled={chatSubmitting || !isChatFormValid}
|
||||
className={cn(
|
||||
'gap-2 font-medium',
|
||||
'bg-[#802FFF] hover:bg-[#7028E6]',
|
||||
'shadow-[0_0_0_0_#802FFF] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]',
|
||||
'text-white transition-all duration-200',
|
||||
'disabled:opacity-50 disabled:hover:bg-[#802FFF] disabled:hover:shadow-none'
|
||||
)}
|
||||
<Button
|
||||
type='button'
|
||||
onClick={handleChatSubmit}
|
||||
disabled={chatSubmitting}
|
||||
className={cn(
|
||||
'gap-2 font-medium',
|
||||
'bg-[#802FFF] hover:bg-[#7028E6]',
|
||||
'shadow-[0_0_0_0_#802FFF] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]',
|
||||
'text-white transition-all duration-200',
|
||||
'disabled:opacity-50 disabled:hover:bg-[#802FFF] disabled:hover:shadow-none'
|
||||
)}
|
||||
>
|
||||
{chatSubmitting ? (
|
||||
<>
|
||||
<Loader2 className='mr-1.5 h-3.5 w-3.5 animate-spin' />
|
||||
{isDeployed
|
||||
? chatExists
|
||||
? 'Updating...'
|
||||
: 'Deploying...'
|
||||
: 'Deploying Workflow...'}
|
||||
</>
|
||||
) : chatExists ? (
|
||||
'Update'
|
||||
) : (
|
||||
'Deploy Chat'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
>
|
||||
{chatSubmitting ? (
|
||||
<>
|
||||
<Loader2 className='mr-1.5 h-3.5 w-3.5 animate-spin' />
|
||||
Deploying...
|
||||
</>
|
||||
) : chatExists ? (
|
||||
'Update'
|
||||
) : (
|
||||
'Deploy Chat'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
Reference in New Issue
Block a user