fix(chat-deploy): fixed form submission access patterns, fixed kb block filters (#839)

* fix(chat-deploy): fixed form submission access patterns

* fix(kb-block): fix tag filters component, removed unused component

* fixed kb block subcomponents

---------

Co-authored-by: waleedlatif <waleedlatif@waleedlatifs-MacBook-Pro.local>
This commit is contained in:
Waleed Latif
2025-08-01 10:29:52 -07:00
committed by GitHub
parent 608964a8b3
commit fae123754d
10 changed files with 560 additions and 456 deletions

View File

@@ -13,9 +13,9 @@ import {
Textarea,
} from '@/components/ui'
import { createLogger } from '@/lib/logs/console/logger'
import { getBaseDomain, getEmailDomain } from '@/lib/urls/utils'
import { AuthSelector } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/components/auth-selector'
import { SubdomainInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/components/subdomain-input'
import { SuccessView } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/components/success-view'
import { useChatDeployment } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/hooks/use-chat-deployment'
import { useChatForm } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/hooks/use-chat-form'
import { OutputSelect } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components'
@@ -31,6 +31,7 @@ interface ChatDeployProps {
chatSubmitting: boolean
setChatSubmitting: (submitting: boolean) => void
onValidationChange?: (isValid: boolean) => void
onPreDeployWorkflow?: () => Promise<void>
}
interface ExistingChat {
@@ -54,22 +55,15 @@ export function ChatDeploy({
chatSubmitting,
setChatSubmitting,
onValidationChange,
onPreDeployWorkflow,
}: ChatDeployProps) {
// Loading and existing chat state
const [isLoading, setIsLoading] = useState(false)
const [existingChat, setExistingChat] = useState<ExistingChat | null>(null)
// Form and deployment hooks
const { formData, errors, updateField, setError, validateForm, setFormData } = useChatForm()
const { deployedUrl, deployChat } = useChatDeployment()
// Refs
const formRef = useRef<HTMLFormElement>(null)
// Subdomain validation state
const [isSubdomainValid, setIsSubdomainValid] = useState(false)
// Track overall form validity
const isFormValid =
isSubdomainValid &&
Boolean(formData.title.trim()) &&
@@ -79,12 +73,10 @@ export function ChatDeploy({
Boolean(existingChat)) &&
(formData.authType !== 'email' || formData.emails.length > 0)
// Notify parent of validation changes
useEffect(() => {
onValidationChange?.(isFormValid)
}, [isFormValid, onValidationChange])
// Fetch existing chat data when component mounts
useEffect(() => {
if (workflowId) {
fetchExistingChat()
@@ -106,13 +98,12 @@ export function ChatDeploy({
const chatDetail = await detailResponse.json()
setExistingChat(chatDetail)
// Populate form with existing data
setFormData({
subdomain: chatDetail.subdomain || '',
title: chatDetail.title || '',
description: chatDetail.description || '',
authType: chatDetail.authType || 'public',
password: '', // Never populate password for security
password: '',
emails: Array.isArray(chatDetail.allowedEmails) ? [...chatDetail.allowedEmails] : [],
welcomeMessage:
chatDetail.customizations?.welcomeMessage || 'Hi there! How can I help you today?',
@@ -141,32 +132,28 @@ export function ChatDeploy({
const handleSubmit = async (e?: React.FormEvent) => {
if (e) e.preventDefault()
// Prevent double submission
if (chatSubmitting) return
setChatSubmitting(true)
try {
// Validate form
await onPreDeployWorkflow?.()
if (!validateForm()) {
setChatSubmitting(false)
return
}
// Check subdomain validation
if (!isSubdomainValid && formData.subdomain !== existingChat?.subdomain) {
setError('subdomain', 'Please wait for subdomain validation to complete')
setChatSubmitting(false)
return
}
// Deploy chat
await deployChat(workflowId, formData, deploymentInfo, existingChat?.id)
// Success - component will show success state via deployedUrl
onChatExistsChange?.(true)
} catch (error: any) {
// Handle subdomain conflict specifically
if (error.message?.includes('subdomain')) {
setError('subdomain', error.message)
} else {
@@ -177,21 +164,6 @@ export function ChatDeploy({
}
}
// Setup form for external submission
useEffect(() => {
const form = formRef.current
if (form) {
const handleDOMSubmit = (e: Event) => {
e.preventDefault()
handleSubmit()
}
form.addEventListener('submit', handleDOMSubmit)
return () => {
form.removeEventListener('submit', handleDOMSubmit)
}
}
}, [handleSubmit])
if (isLoading) {
return <LoadingSkeleton />
}
@@ -202,9 +174,10 @@ export function ChatDeploy({
return (
<form
id='chat-deploy-form'
ref={formRef}
onSubmit={handleSubmit}
className='chat-deploy-form -mx-1 space-y-4 overflow-y-auto px-1'
className='-mx-1 space-y-4 overflow-y-auto px-1'
>
{errors.general && (
<Alert variant='destructive'>
@@ -214,7 +187,6 @@ export function ChatDeploy({
)}
<div className='space-y-4'>
{/* Subdomain Input */}
<SubdomainInput
value={formData.subdomain}
onChange={(value) => updateField('subdomain', value)}
@@ -222,8 +194,6 @@ export function ChatDeploy({
disabled={chatSubmitting}
onValidationChange={setIsSubdomainValid}
/>
{/* Title Input */}
<div className='space-y-2'>
<Label htmlFor='title' className='font-medium text-sm'>
Chat Title
@@ -238,8 +208,6 @@ export function ChatDeploy({
/>
{errors.title && <p className='text-destructive text-sm'>{errors.title}</p>}
</div>
{/* Description Input */}
<div className='space-y-2'>
<Label htmlFor='description' className='font-medium text-sm'>
Description (Optional)
@@ -253,8 +221,6 @@ export function ChatDeploy({
disabled={chatSubmitting}
/>
</div>
{/* Output Configuration */}
<div className='space-y-2'>
<Label className='font-medium text-sm'>Chat Output</Label>
<Card className='rounded-md border-input shadow-none'>
@@ -274,7 +240,6 @@ export function ChatDeploy({
</p>
</div>
{/* Authentication Selector */}
<AuthSelector
authType={formData.authType}
password={formData.password}
@@ -286,8 +251,6 @@ export function ChatDeploy({
isExistingChat={!!existingChat}
error={errors.password || errors.emails}
/>
{/* Welcome Message */}
<div className='space-y-2'>
<Label htmlFor='welcomeMessage' className='font-medium text-sm'>
Welcome Message
@@ -331,64 +294,3 @@ function LoadingSkeleton() {
</div>
)
}
function SuccessView({
deployedUrl,
existingChat,
}: {
deployedUrl: string
existingChat: ExistingChat | null
}) {
const url = new URL(deployedUrl)
const hostname = url.hostname
const isDevelopmentUrl = hostname.includes('localhost')
let domainSuffix
if (isDevelopmentUrl) {
const baseDomain = getBaseDomain()
const baseHost = baseDomain.split(':')[0]
const port = url.port || (baseDomain.includes(':') ? baseDomain.split(':')[1] : '3000')
domainSuffix = `.${baseHost}:${port}`
} else {
domainSuffix = `.${getEmailDomain()}`
}
const baseDomainForSplit = getEmailDomain()
const subdomainPart = isDevelopmentUrl
? hostname.split('.')[0]
: hostname.split(`.${baseDomainForSplit}`)[0]
return (
<div className='space-y-4'>
<div className='space-y-2'>
<Label className='font-medium text-sm'>
Chat {existingChat ? 'Update' : 'Deployment'} Successful
</Label>
<div className='relative flex items-center rounded-md ring-offset-background'>
<a
href={deployedUrl}
target='_blank'
rel='noopener noreferrer'
className='flex h-10 flex-1 items-center break-all rounded-l-md border border-r-0 p-2 font-medium text-primary text-sm'
>
{subdomainPart}
</a>
<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'>
{domainSuffix}
</div>
</div>
<p className='text-muted-foreground text-xs'>
Your chat is now live at{' '}
<a
href={deployedUrl}
target='_blank'
rel='noopener noreferrer'
className='text-primary hover:underline'
>
this URL
</a>
</p>
</div>
</div>
)
}

View File

@@ -139,6 +139,7 @@ export function AuthSelector({
disabled={disabled}
className='pr-28'
required={!isExistingChat}
autoComplete='new-password'
/>
<div className='absolute top-0 right-0 flex h-full'>
<Button

View File

@@ -0,0 +1,76 @@
import { Label } from '@/components/ui'
import { getBaseDomain, getEmailDomain } from '@/lib/urls/utils'
interface ExistingChat {
id: string
subdomain: string
title: string
description: string
authType: 'public' | 'password' | 'email'
allowedEmails: string[]
outputConfigs: Array<{ blockId: string; path: string }>
customizations?: {
welcomeMessage?: string
}
isActive: boolean
}
interface SuccessViewProps {
deployedUrl: string
existingChat: ExistingChat | null
}
export function SuccessView({ deployedUrl, existingChat }: SuccessViewProps) {
const url = new URL(deployedUrl)
const hostname = url.hostname
const isDevelopmentUrl = hostname.includes('localhost')
let domainSuffix
if (isDevelopmentUrl) {
const baseDomain = getBaseDomain()
const baseHost = baseDomain.split(':')[0]
const port = url.port || (baseDomain.includes(':') ? baseDomain.split(':')[1] : '3000')
domainSuffix = `.${baseHost}:${port}`
} else {
domainSuffix = `.${getEmailDomain()}`
}
const baseDomainForSplit = getEmailDomain()
const subdomainPart = isDevelopmentUrl
? hostname.split('.')[0]
: hostname.split(`.${baseDomainForSplit}`)[0]
return (
<div className='space-y-4'>
<div className='space-y-2'>
<Label className='font-medium text-sm'>
Chat {existingChat ? 'Update' : 'Deployment'} Successful
</Label>
<div className='relative flex items-center rounded-md ring-offset-background'>
<a
href={deployedUrl}
target='_blank'
rel='noopener noreferrer'
className='flex h-10 flex-1 items-center break-all rounded-l-md border border-r-0 p-2 font-medium text-primary text-sm'
>
{subdomainPart}
</a>
<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'>
{domainSuffix}
</div>
</div>
<p className='text-muted-foreground text-xs'>
Your chat is now live at{' '}
<a
href={deployedUrl}
target='_blank'
rel='noopener noreferrer'
className='text-primary hover:underline'
>
this URL
</a>
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,6 @@
import { useChatDeployment } from './use-chat-deployment'
import { useChatForm } from './use-chat-form'
export { useChatDeployment, useChatForm }
export type { ChatFormData, ChatFormErrors } from './use-chat-form'

View File

@@ -64,14 +64,11 @@ export function DeployModal({
isLoadingDeployedState,
refetchDeployedState,
}: DeployModalProps) {
// Use registry store for deployment-related functions
const deploymentStatus = useWorkflowRegistry((state) =>
state.getWorkflowDeploymentStatus(workflowId)
)
const isDeployed = deploymentStatus?.isDeployed || false
const setDeploymentStatus = useWorkflowRegistry((state) => state.setDeploymentStatus)
// Local state
const [isSubmitting, setIsSubmitting] = useState(false)
const [isUndeploying, setIsUndeploying] = useState(false)
const [deploymentInfo, setDeploymentInfo] = useState<WorkflowDeploymentInfo | null>(null)
@@ -84,7 +81,6 @@ export function DeployModal({
const [chatExists, setChatExists] = useState(false)
const [isChatFormValid, setIsChatFormValid] = useState(false)
// Generate an example input format for the API request
const getInputFormatExample = () => {
let inputFormatExample = ''
try {
@@ -128,7 +124,6 @@ export function DeployModal({
return inputFormatExample
}
// Fetch API keys when modal opens
const fetchApiKeys = async () => {
if (!open) return
@@ -147,7 +142,6 @@ export function DeployModal({
}
}
// Fetch chat deployment info when modal opens
const fetchChatDeploymentInfo = async () => {
if (!open || !workflowId) return
@@ -173,10 +167,8 @@ export function DeployModal({
}
}
// Call fetchApiKeys when the modal opens
useEffect(() => {
if (open) {
// Set loading state immediately when modal opens
setIsLoading(true)
fetchApiKeys()
fetchChatDeploymentInfo()
@@ -184,12 +176,10 @@ export function DeployModal({
}
}, [open, workflowId])
// Fetch deployment info when the modal opens and the workflow is deployed
useEffect(() => {
async function fetchDeploymentInfo() {
if (!open || !workflowId || !isDeployed) {
setDeploymentInfo(null)
// Only reset loading if modal is closed
if (!open) {
setIsLoading(false)
}
@@ -199,7 +189,6 @@ export function DeployModal({
try {
setIsLoading(true)
// Get deployment info
const response = await fetch(`/api/workflows/${workflowId}/deploy`)
if (!response.ok) {
@@ -228,15 +217,12 @@ export function DeployModal({
fetchDeploymentInfo()
}, [open, workflowId, isDeployed, needsRedeployment])
// Handle form submission for deployment
const onDeploy = async (data: DeployFormValues) => {
// Reset any previous errors
setApiDeployError(null)
try {
setIsSubmitting(true)
// Deploy the workflow with the selected API key
const response = await fetch(`/api/workflows/${workflowId}/deploy`, {
method: 'POST',
headers: {
@@ -244,7 +230,7 @@ export function DeployModal({
},
body: JSON.stringify({
apiKey: data.apiKey,
deployChatEnabled: false, // Separate chat deployment
deployChatEnabled: false,
}),
})
@@ -255,7 +241,6 @@ export function DeployModal({
const { isDeployed: newDeployStatus, deployedAt } = await response.json()
// Update the store with the deployment status
setDeploymentStatus(
workflowId,
newDeployStatus,
@@ -263,13 +248,10 @@ export function DeployModal({
data.apiKey
)
// Reset the needs redeployment flag
setNeedsRedeployment(false)
if (workflowId) {
useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(workflowId, false)
}
// Update the local deployment info
const endpoint = `${getEnv('NEXT_PUBLIC_APP_URL')}/api/workflows/${workflowId}/execute`
const inputFormatExample = getInputFormatExample()
@@ -284,10 +266,7 @@ export function DeployModal({
setDeploymentInfo(newDeploymentInfo)
// Fetch the updated deployed state after deployment
await refetchDeployedState()
// No notification on successful deploy
} catch (error: any) {
logger.error('Error deploying workflow:', { error })
} finally {
@@ -295,7 +274,6 @@ export function DeployModal({
}
}
// Handle workflow undeployment
const handleUndeploy = async () => {
try {
setIsUndeploying(true)
@@ -309,13 +287,8 @@ export function DeployModal({
throw new Error(errorData.error || 'Failed to undeploy workflow')
}
// Update deployment status in the store
setDeploymentStatus(workflowId, false)
// Reset chat deployment info
setChatExists(false)
// Close the modal
onOpenChange(false)
} catch (error: any) {
logger.error('Error undeploying workflow:', { error })
@@ -324,7 +297,6 @@ export function DeployModal({
}
}
// Handle redeployment of workflow
const handleRedeploy = async () => {
try {
setIsSubmitting(true)
@@ -335,7 +307,7 @@ export function DeployModal({
'Content-Type': 'application/json',
},
body: JSON.stringify({
deployChatEnabled: false, // Separate chat deployment
deployChatEnabled: false,
}),
})
@@ -346,7 +318,6 @@ export function DeployModal({
const { isDeployed: newDeployStatus, deployedAt, apiKey } = await response.json()
// Update deployment status in the store
setDeploymentStatus(
workflowId,
newDeployStatus,
@@ -354,13 +325,11 @@ export function DeployModal({
apiKey
)
// Reset the needs redeployment flag
setNeedsRedeployment(false)
if (workflowId) {
useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(workflowId, false)
}
// Fetch the updated deployed state after redeployment
await refetchDeployedState()
} catch (error: any) {
logger.error('Error redeploying workflow:', { error })
@@ -369,56 +338,47 @@ export function DeployModal({
}
}
// Custom close handler to ensure we clean up loading states
const handleCloseModal = () => {
setIsSubmitting(false)
setChatSubmitting(false)
onOpenChange(false)
}
// Handle chat form submission
const handleChatSubmit = async () => {
setChatSubmitting(true)
const handleWorkflowPreDeploy = async () => {
if (!isDeployed) {
const response = await fetch(`/api/workflows/${workflowId}/deploy`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
deployApiEnabled: true,
deployChatEnabled: false,
}),
})
try {
// Check if workflow is deployed first
if (!isDeployed) {
// Deploy workflow first
const response = await fetch(`/api/workflows/${workflowId}/deploy`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
deployApiEnabled: true,
deployChatEnabled: false,
}),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to deploy workflow')
}
const { isDeployed: newDeployStatus, deployedAt, apiKey } = await response.json()
// Update the store with the deployment status
setDeploymentStatus(
workflowId,
newDeployStatus,
deployedAt ? new Date(deployedAt) : undefined,
apiKey
)
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to deploy workflow')
}
// 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)
const { isDeployed: newDeployStatus, deployedAt, apiKey } = await response.json()
setDeploymentStatus(
workflowId,
newDeployStatus,
deployedAt ? new Date(deployedAt) : undefined,
apiKey
)
setDeploymentInfo((prev) => (prev ? { ...prev, apiKey } : null))
}
}
const handleChatFormSubmit = () => {
const form = document.getElementById('chat-deploy-form') as HTMLFormElement
if (form) {
form.requestSubmit()
}
}
@@ -513,13 +473,13 @@ export function DeployModal({
chatSubmitting={chatSubmitting}
setChatSubmitting={setChatSubmitting}
onValidationChange={setIsChatFormValid}
onPreDeployWorkflow={handleWorkflowPreDeploy}
/>
)}
</div>
</div>
</div>
{/* Footer buttons */}
{activeTab === 'api' && !isDeployed && (
<div className='flex flex-shrink-0 justify-between border-t px-6 py-4'>
<Button variant='outline' onClick={handleCloseModal}>
@@ -558,7 +518,7 @@ export function DeployModal({
<Button
type='button'
onClick={handleChatSubmit}
onClick={handleChatFormSubmit}
disabled={chatSubmitting || !isChatFormValid}
className={cn(
'gap-2 font-medium',

View File

@@ -5,13 +5,6 @@ import { Plus, Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { formatDisplayText } from '@/components/ui/formatted-text'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { checkTagTrigger, TagDropdown } from '@/components/ui/tag-dropdown'
import { MAX_TAG_SLOTS } from '@/lib/constants/knowledge'
import { cn } from '@/lib/utils'
@@ -19,13 +12,6 @@ import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/c
import type { SubBlockConfig } from '@/blocks/types'
import { useKnowledgeBaseTagDefinitions } from '@/hooks/use-knowledge-base-tag-definitions'
export interface DocumentTag {
slot: string
displayName: string
fieldType: string
value: string
}
interface DocumentTagRow {
id: string
cells: {
@@ -63,6 +49,8 @@ export function DocumentTagEntry({
// State for dropdown visibility - one for each row
const [dropdownStates, setDropdownStates] = useState<Record<number, boolean>>({})
// State for type dropdown visibility - one for each row
const [typeDropdownStates, setTypeDropdownStates] = useState<Record<number, boolean>>({})
// State for managing tag dropdown
const [activeTagDropdown, setActiveTagDropdown] = useState<{
@@ -84,7 +72,7 @@ export function DocumentTagEntry({
const tagData = JSON.parse(currentValue)
if (Array.isArray(tagData) && tagData.length > 0) {
return tagData.map((tag: any, index: number) => ({
id: `tag-${index}`,
id: tag.id || `tag-${index}`,
cells: {
tagName: tag.tagName || '',
type: tag.fieldType || 'text',
@@ -100,7 +88,7 @@ export function DocumentTagEntry({
// Default: just one empty row
return [
{
id: 'empty-row',
id: 'empty-row-0',
cells: { tagName: '', type: 'text', value: '' },
},
]
@@ -129,7 +117,8 @@ export function DocumentTagEntry({
const handlePreFillTags = () => {
if (isPreview || disabled) return
const existingTagRows = tagDefinitions.map((tagDef) => ({
const existingTagRows = tagDefinitions.map((tagDef, index) => ({
id: `prefill-${tagDef.id}-${index}`,
tagName: tagDef.displayName,
fieldType: tagDef.fieldType,
value: '',
@@ -192,6 +181,7 @@ export function DocumentTagEntry({
// Store all rows including empty ones - don't auto-remove
const dataToStore = updatedRows.map((row) => ({
id: row.id,
tagName: row.cells.tagName || '',
fieldType: row.cells.type || 'text',
value: row.cells.value || '',
@@ -206,7 +196,8 @@ export function DocumentTagEntry({
// Get current data and add a new empty row
const currentData = currentValue ? JSON.parse(currentValue) : []
const newData = [...currentData, { tagName: '', fieldType: 'text', value: '' }]
const newRowId = `tag-${currentData.length}-${Math.random().toString(36).substr(2, 9)}`
const newData = [...currentData, { id: newRowId, tagName: '', fieldType: 'text', value: '' }]
setStoreValue(JSON.stringify(newData))
}
@@ -216,6 +207,7 @@ export function DocumentTagEntry({
// Store all remaining rows including empty ones - don't auto-remove
const tableDataForStorage = updatedRows.map((row) => ({
id: row.id,
tagName: row.cells.tagName || '',
fieldType: row.cells.type || 'text',
value: row.cells.value || '',
@@ -244,8 +236,8 @@ export function DocumentTagEntry({
const renderHeader = () => (
<thead>
<tr className='border-b'>
<th className='border-r px-4 py-2 text-center font-medium text-sm'>Tag Name</th>
<th className='border-r px-4 py-2 text-center font-medium text-sm'>Type</th>
<th className='w-2/5 border-r px-4 py-2 text-center font-medium text-sm'>Tag Name</th>
<th className='w-1/5 border-r px-4 py-2 text-center font-medium text-sm'>Type</th>
<th className='px-4 py-2 text-center font-medium text-sm'>Value</th>
</tr>
</thead>
@@ -260,35 +252,70 @@ export function DocumentTagEntry({
setDropdownStates((prev) => ({ ...prev, [rowIndex]: show }))
}
const handleDropdownClick = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
if (!disabled && !isConnecting) {
if (!showDropdown) {
setShowDropdown(true)
}
}
}
const handleFocus = () => {
if (!disabled && !isConnecting) {
setShowDropdown(true)
}
}
const handleBlur = () => {
// Delay closing to allow dropdown selection
setTimeout(() => setShowDropdown(false), 150)
}
return (
<td className='relative border-r p-1'>
<div className='relative w-full'>
<Input
value={cellValue}
onChange={(e) => handleCellChange(rowIndex, 'tagName', e.target.value)}
onFocus={() => setShowDropdown(true)}
onBlur={() => setTimeout(() => setShowDropdown(false), 200)}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={disabled || isConnecting}
className={cn(isDuplicate && 'border-red-500 bg-red-50')}
className={cn(
'w-full border-0 text-transparent caret-foreground placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0',
isDuplicate && 'border-red-500 bg-red-50'
)}
/>
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden bg-transparent px-3 text-sm'>
<div className='whitespace-pre'>{formatDisplayText(cellValue)}</div>
</div>
{showDropdown && availableTagDefinitions.length > 0 && (
<div className='absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md border border-border bg-popover shadow-md'>
{availableTagDefinitions
.filter((tagDef) =>
tagDef.displayName.toLowerCase().includes(cellValue.toLowerCase())
)
.map((tagDef) => (
<div
key={tagDef.id}
className='cursor-pointer px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground'
onMouseDown={() => {
handleCellChange(rowIndex, 'tagName', tagDef.displayName)
setShowDropdown(false)
}}
>
{tagDef.displayName}
</div>
))}
<div className='absolute top-full left-0 z-[100] mt-1 w-full'>
<div className='allow-scroll fade-in-0 zoom-in-95 animate-in rounded-md border bg-popover text-popover-foreground shadow-lg'>
<div
className='allow-scroll max-h-48 overflow-y-auto p-1'
style={{ scrollbarWidth: 'thin' }}
>
{availableTagDefinitions
.filter((tagDef) =>
tagDef.displayName.toLowerCase().includes(cellValue.toLowerCase())
)
.map((tagDef) => (
<div
key={tagDef.id}
className='relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground'
onMouseDown={(e) => {
e.preventDefault()
handleCellChange(rowIndex, 'tagName', tagDef.displayName)
setShowDropdown(false)
}}
>
<span className='flex-1 truncate'>{tagDef.displayName}</span>
</div>
))}
</div>
</div>
</div>
)}
</div>
@@ -306,25 +333,77 @@ export function DocumentTagEntry({
)
const isReadOnly = !!existingTag
const showTypeDropdown = typeDropdownStates[rowIndex] || false
const setShowTypeDropdown = (show: boolean) => {
setTypeDropdownStates((prev) => ({ ...prev, [rowIndex]: show }))
}
const handleTypeDropdownClick = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
if (!disabled && !isConnecting && !isReadOnly) {
if (!showTypeDropdown) {
setShowTypeDropdown(true)
}
}
}
const handleTypeFocus = () => {
if (!disabled && !isConnecting && !isReadOnly) {
setShowTypeDropdown(true)
}
}
const handleTypeBlur = () => {
// Delay closing to allow dropdown selection
setTimeout(() => setShowTypeDropdown(false), 150)
}
const typeOptions = [{ value: 'text', label: 'Text' }]
return (
<td className='border-r p-1'>
<Select
value={cellValue}
onValueChange={(value) => handleCellChange(rowIndex, 'type', value)}
disabled={disabled || isConnecting || isReadOnly}
>
<SelectTrigger
className={cn(
isReadOnly && 'bg-gray-50 dark:bg-gray-800',
'text-foreground' // Ensure proper text color in dark mode
)}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='text'>Text</SelectItem>
</SelectContent>
</Select>
<div className='relative w-full'>
<Input
value={cellValue}
readOnly
disabled={disabled || isConnecting || isReadOnly}
className='w-full cursor-pointer border-0 text-transparent caret-foreground placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0'
onClick={handleTypeDropdownClick}
onFocus={handleTypeFocus}
onBlur={handleTypeBlur}
/>
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden bg-transparent px-3 text-sm'>
<div className='whitespace-pre text-muted-foreground'>
{formatDisplayText(cellValue)}
</div>
</div>
{showTypeDropdown && !isReadOnly && (
<div className='absolute top-full left-0 z-[100] mt-1 w-full'>
<div className='allow-scroll fade-in-0 zoom-in-95 animate-in rounded-md border bg-popover text-popover-foreground shadow-lg'>
<div
className='allow-scroll max-h-48 overflow-y-auto p-1'
style={{ scrollbarWidth: 'thin' }}
>
{typeOptions.map((option) => (
<div
key={option.value}
className='relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground'
onMouseDown={(e) => {
e.preventDefault()
handleCellChange(rowIndex, 'type', option.value)
setShowTypeDropdown(false)
}}
>
<span className='flex-1 truncate'>{option.label}</span>
</div>
))}
</div>
</div>
</div>
)}
</div>
</td>
)
}
@@ -458,8 +537,14 @@ export function DocumentTagEntry({
{/* Add Row Button and Tag slots usage indicator */}
{!isPreview && !disabled && (
<div className='mt-3 flex items-center justify-between'>
<Button variant='outline' size='sm' onClick={handleAddRow} disabled={!canAddMoreTags}>
<Plus className='mr-1 h-3 w-3' />
<Button
variant='outline'
size='sm'
onClick={handleAddRow}
disabled={!canAddMoreTags}
className='h-7 px-2 text-xs'
>
<Plus className='mr-1 h-2.5 w-2.5' />
Add Tag
</Button>

View File

@@ -1,118 +0,0 @@
'use client'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import type { SubBlockConfig } from '@/blocks/types'
import { useKnowledgeBaseTagDefinitions } from '@/hooks/use-knowledge-base-tag-definitions'
import { useSubBlockValue } from '../../hooks/use-sub-block-value'
interface KnowledgeTagFilterProps {
blockId: string
subBlock: SubBlockConfig
disabled?: boolean
isPreview?: boolean
previewValue?: string | null
isConnecting?: boolean
}
export function KnowledgeTagFilter({
blockId,
subBlock,
disabled = false,
isPreview = false,
previewValue,
isConnecting = false,
}: KnowledgeTagFilterProps) {
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
// Get the knowledge base ID and document ID from other sub-blocks
const [knowledgeBaseIdValue] = useSubBlockValue(blockId, 'knowledgeBaseIds')
const [knowledgeBaseIdSingleValue] = useSubBlockValue(blockId, 'knowledgeBaseId')
const [documentIdValue] = useSubBlockValue(blockId, 'documentId')
// Determine which knowledge base ID to use
const knowledgeBaseId =
knowledgeBaseIdSingleValue ||
(typeof knowledgeBaseIdValue === 'string' ? knowledgeBaseIdValue.split(',')[0] : null)
// Use KB tag definitions hook to get available tags
const { tagDefinitions, isLoading, getTagLabel } = useKnowledgeBaseTagDefinitions(knowledgeBaseId)
// Parse the current value to extract tag name and value
const parseTagFilter = (filterValue: string) => {
if (!filterValue) return { tagName: '', tagValue: '' }
const [tagName, ...valueParts] = filterValue.split(':')
return { tagName: tagName?.trim() || '', tagValue: valueParts.join(':').trim() || '' }
}
const currentValue = isPreview ? previewValue : storeValue
const { tagName, tagValue } = parseTagFilter(currentValue || '')
const handleTagNameChange = (newTagName: string) => {
if (isPreview) return
const newValue =
newTagName && tagValue ? `${newTagName}:${tagValue}` : newTagName || tagValue || ''
setStoreValue(newValue.trim() || null)
}
const handleTagValueChange = (newTagValue: string) => {
if (isPreview) return
const newValue =
tagName && newTagValue ? `${tagName}:${newTagValue}` : tagName || newTagValue || ''
setStoreValue(newValue.trim() || null)
}
if (isPreview) {
return (
<div className='space-y-1'>
<Label className='font-medium text-muted-foreground text-xs'>Tag Filter</Label>
<Input
value={currentValue || ''}
disabled
placeholder='Tag filter preview'
className='text-sm'
/>
</div>
)
}
return (
<div className='space-y-2'>
{/* Tag Name Selector */}
<Select
value={tagName}
onValueChange={handleTagNameChange}
disabled={disabled || isConnecting || isLoading}
>
<SelectTrigger className='text-sm'>
<SelectValue placeholder='Select tag' />
</SelectTrigger>
<SelectContent>
{tagDefinitions.map((tag) => (
<SelectItem key={tag.id} value={tag.displayName}>
{tag.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Tag Value Input - only show if tag is selected */}
{tagName && (
<Input
value={tagValue}
onChange={(e) => handleTagValueChange(e.target.value)}
placeholder={`Enter ${tagName} value`}
disabled={disabled || isConnecting}
className='text-sm'
/>
)}
</div>
)
}

View File

@@ -1,16 +1,12 @@
'use client'
import { Plus, X } from 'lucide-react'
import { useState } from 'react'
import { Plus, Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { formatDisplayText } from '@/components/ui/formatted-text'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { checkTagTrigger, TagDropdown } from '@/components/ui/tag-dropdown'
import type { SubBlockConfig } from '@/blocks/types'
import { useKnowledgeBaseTagDefinitions } from '@/hooks/use-knowledge-base-tag-definitions'
import { useSubBlockValue } from '../../hooks/use-sub-block-value'
@@ -21,6 +17,14 @@ interface TagFilter {
tagValue: string
}
interface TagFilterRow {
id: string
cells: {
tagName: string
value: string
}
}
interface KnowledgeTagFiltersProps {
blockId: string
subBlock: SubBlockConfig
@@ -38,7 +42,7 @@ export function KnowledgeTagFilters({
previewValue,
isConnecting = false,
}: KnowledgeTagFiltersProps) {
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
const [storeValue, setStoreValue] = useSubBlockValue<string | null>(blockId, subBlock.id)
// Get the knowledge base ID from other sub-blocks
const [knowledgeBaseIdValue] = useSubBlockValue(blockId, 'knowledgeBaseId')
@@ -47,8 +51,20 @@ export function KnowledgeTagFilters({
// Use KB tag definitions hook to get available tags
const { tagDefinitions, isLoading } = useKnowledgeBaseTagDefinitions(knowledgeBaseId)
// State for managing tag dropdown
const [activeTagDropdown, setActiveTagDropdown] = useState<{
rowIndex: number
showTags: boolean
cursorPosition: number
activeSourceBlockId: string | null
element?: HTMLElement | null
} | null>(null)
// State for dropdown visibility - one for each row
const [dropdownStates, setDropdownStates] = useState<Record<number, boolean>>({})
// Parse the current value to extract filters
const parseFilters = (filterValue: string): TagFilter[] => {
const parseFilters = (filterValue: string | null): TagFilter[] => {
if (!filterValue) return []
try {
return JSON.parse(filterValue)
@@ -58,7 +74,24 @@ export function KnowledgeTagFilters({
}
const currentValue = isPreview ? previewValue : storeValue
const filters = parseFilters(currentValue || '')
const filters = parseFilters(currentValue || null)
// Transform filters to table format for display
const rows: TagFilterRow[] =
filters.length > 0
? filters.map((filter) => ({
id: filter.id,
cells: {
tagName: filter.tagName || '',
value: filter.tagValue || '',
},
}))
: [
{
id: 'empty-row-0',
cells: { tagName: '', value: '' },
},
]
const updateFilters = (newFilters: TagFilter[]) => {
if (isPreview) return
@@ -66,21 +99,48 @@ export function KnowledgeTagFilters({
setStoreValue(value)
}
const addFilter = () => {
const newFilter: TagFilter = {
id: Date.now().toString(),
tagName: '',
tagValue: '',
}
updateFilters([...filters, newFilter])
const handleCellChange = (rowIndex: number, column: string, value: string) => {
if (isPreview || disabled) return
const updatedRows = [...rows].map((row, idx) => {
if (idx === rowIndex) {
return {
...row,
cells: { ...row.cells, [column]: value },
}
}
return row
})
// Convert back to TagFilter format - keep all rows, even empty ones
const updatedFilters = updatedRows.map((row) => ({
id: row.id,
tagName: row.cells.tagName || '',
tagValue: row.cells.value || '',
}))
updateFilters(updatedFilters)
}
const removeFilter = (filterId: string) => {
updateFilters(filters.filter((f) => f.id !== filterId))
const handleAddRow = () => {
if (isPreview || disabled) return
const newRowId = `filter-${filters.length}-${Math.random().toString(36).substr(2, 9)}`
const newFilters = [...filters, { id: newRowId, tagName: '', tagValue: '' }]
updateFilters(newFilters)
}
const updateFilter = (filterId: string, field: keyof TagFilter, value: string) => {
updateFilters(filters.map((f) => (f.id === filterId ? { ...f, [field]: value } : f)))
const handleDeleteRow = (rowIndex: number) => {
if (isPreview || disabled || rows.length <= 1) return
const updatedRows = rows.filter((_, idx) => idx !== rowIndex)
const updatedFilters = updatedRows.map((row) => ({
id: row.id,
tagName: row.cells.tagName || '',
tagValue: row.cells.value || '',
}))
updateFilters(updatedFilters)
}
if (isPreview) {
@@ -88,82 +148,227 @@ export function KnowledgeTagFilters({
<div className='space-y-1'>
<Label className='font-medium text-muted-foreground text-xs'>Tag Filters</Label>
<div className='text-muted-foreground text-sm'>
{filters.length > 0 ? `${filters.length} filter(s)` : 'No filters'}
{filters.length > 0 ? `${filters.length} filter(s) applied` : 'No filters'}
</div>
</div>
)
}
return (
<div className='space-y-3'>
<div className='flex items-center justify-end'>
const renderHeader = () => (
<thead>
<tr className='border-b'>
<th className='w-2/5 border-r px-4 py-2 text-center font-medium text-sm'>Tag Name</th>
<th className='px-4 py-2 text-center font-medium text-sm'>Value</th>
</tr>
</thead>
)
const renderTagNameCell = (row: TagFilterRow, rowIndex: number) => {
const cellValue = row.cells.tagName || ''
const showDropdown = dropdownStates[rowIndex] || false
const setShowDropdown = (show: boolean) => {
setDropdownStates((prev) => ({ ...prev, [rowIndex]: show }))
}
const handleDropdownClick = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
if (!disabled && !isConnecting && !isLoading) {
if (!showDropdown) {
setShowDropdown(true)
}
}
}
const handleFocus = () => {
if (!disabled && !isConnecting && !isLoading) {
setShowDropdown(true)
}
}
const handleBlur = () => {
// Delay closing to allow dropdown selection
setTimeout(() => setShowDropdown(false), 150)
}
return (
<td className='relative border-r p-1'>
<div className='relative w-full'>
<Input
value={cellValue}
readOnly
disabled={disabled || isConnecting || isLoading}
className='w-full cursor-pointer border-0 text-transparent caret-foreground placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0'
onClick={handleDropdownClick}
onFocus={handleFocus}
onBlur={handleBlur}
/>
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden bg-transparent px-3 text-sm'>
<div className='whitespace-pre'>{formatDisplayText(cellValue || 'Select tag')}</div>
</div>
{showDropdown && tagDefinitions.length > 0 && (
<div className='absolute top-full left-0 z-[100] mt-1 w-full'>
<div className='allow-scroll fade-in-0 zoom-in-95 animate-in rounded-md border bg-popover text-popover-foreground shadow-lg'>
<div
className='allow-scroll max-h-48 overflow-y-auto p-1'
style={{ scrollbarWidth: 'thin' }}
>
{tagDefinitions.map((tag) => (
<div
key={tag.id}
className='relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground'
onMouseDown={(e) => {
e.preventDefault()
handleCellChange(rowIndex, 'tagName', tag.displayName)
setShowDropdown(false)
}}
>
<span className='flex-1 truncate'>{tag.displayName}</span>
</div>
))}
</div>
</div>
</div>
)}
</div>
</td>
)
}
const renderValueCell = (row: TagFilterRow, rowIndex: number) => {
const cellValue = row.cells.value || ''
return (
<td className='p-1'>
<div className='relative w-full'>
<Input
value={cellValue}
onChange={(e) => {
const newValue = e.target.value
const cursorPosition = e.target.selectionStart ?? 0
handleCellChange(rowIndex, 'value', newValue)
// Check for tag trigger
const tagTrigger = checkTagTrigger(newValue, cursorPosition)
setActiveTagDropdown({
rowIndex,
showTags: tagTrigger.show,
cursorPosition,
activeSourceBlockId: null,
element: e.target,
})
}}
onFocus={(e) => {
if (!disabled && !isConnecting) {
setActiveTagDropdown({
rowIndex,
showTags: false,
cursorPosition: 0,
activeSourceBlockId: null,
element: e.target,
})
}
}}
onBlur={() => {
setTimeout(() => setActiveTagDropdown(null), 200)
}}
onKeyDown={(e) => {
if (e.key === 'Escape') {
setActiveTagDropdown(null)
}
}}
disabled={disabled || isConnecting}
className='w-full border-0 text-transparent caret-foreground placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0'
/>
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden bg-transparent px-3 text-sm'>
<div className='whitespace-pre'>{formatDisplayText(cellValue)}</div>
</div>
</div>
</td>
)
}
const renderDeleteButton = (rowIndex: number) => {
const canDelete = !isPreview && !disabled
return canDelete ? (
<td className='w-0 p-0'>
<Button
type='button'
variant='ghost'
size='sm'
onClick={addFilter}
disabled={disabled || isConnecting || isLoading}
className='h-6 px-2 text-xs'
size='icon'
className='-translate-y-1/2 absolute top-1/2 right-2 h-8 w-8 opacity-0 group-hover:opacity-100'
onClick={() => handleDeleteRow(rowIndex)}
>
<Plus className='mr-1 h-3 w-3' />
Add Filter
<Trash2 className='h-4 w-4 text-muted-foreground' />
</Button>
</td>
) : null
}
if (isLoading) {
return <div className='p-4 text-muted-foreground text-sm'>Loading tag definitions...</div>
}
return (
<div className='relative'>
<div className='overflow-visible rounded-md border'>
<table className='w-full'>
{renderHeader()}
<tbody>
{rows.map((row, rowIndex) => (
<tr key={row.id} className='group relative border-t'>
{renderTagNameCell(row, rowIndex)}
{renderValueCell(row, rowIndex)}
{renderDeleteButton(rowIndex)}
</tr>
))}
</tbody>
</table>
</div>
{filters.length === 0 && (
<div className='py-4 text-center text-muted-foreground text-sm'>
No tag filters. Click "Add Filter" to add one.
</div>
{/* Tag Dropdown */}
{activeTagDropdown?.element && (
<TagDropdown
visible={activeTagDropdown.showTags}
onSelect={(newValue) => {
handleCellChange(activeTagDropdown.rowIndex, 'value', newValue)
setActiveTagDropdown(null)
}}
blockId={blockId}
activeSourceBlockId={activeTagDropdown.activeSourceBlockId}
inputValue={rows[activeTagDropdown.rowIndex]?.cells.value || ''}
cursorPosition={activeTagDropdown.cursorPosition}
onClose={() => {
setActiveTagDropdown((prev) => (prev ? { ...prev, showTags: false } : null))
}}
className='absolute z-[9999] mt-0'
/>
)}
<div className='space-y-2'>
{filters.map((filter) => (
<div key={filter.id} className='flex items-center gap-2 rounded-md border p-2'>
{/* Tag Name Selector */}
<div className='flex-1'>
<Select
value={filter.tagName}
onValueChange={(value) => updateFilter(filter.id, 'tagName', value)}
disabled={disabled || isConnecting || isLoading}
>
<SelectTrigger className='h-8 text-sm'>
<SelectValue placeholder='Select tag' />
</SelectTrigger>
<SelectContent>
{tagDefinitions.map((tag) => (
<SelectItem key={tag.id} value={tag.displayName}>
{tag.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Add Filter Button */}
{!isPreview && !disabled && (
<div className='mt-3 flex items-center justify-between'>
<Button variant='outline' size='sm' onClick={handleAddRow} className='h-7 px-2 text-xs'>
<Plus className='mr-1 h-2.5 w-2.5' />
Add Filter
</Button>
{/* Tag Value Input */}
<div className='flex-1'>
<Input
value={filter.tagValue}
onChange={(e) => updateFilter(filter.id, 'tagValue', e.target.value)}
placeholder={filter.tagName ? `Enter ${filter.tagName} value` : 'Enter value'}
disabled={disabled || isConnecting}
className='h-8 text-sm'
/>
</div>
{/* Remove Button */}
<Button
type='button'
variant='ghost'
size='sm'
onClick={() => removeFilter(filter.id)}
disabled={disabled || isConnecting}
className='h-8 w-8 p-0 text-muted-foreground hover:text-destructive'
>
<X className='h-3 w-3' />
</Button>
</div>
))}
</div>
{/* Filter count indicator */}
{(() => {
const appliedFilters = filters.filter(
(f) => f.tagName.trim() || f.tagValue.trim()
).length
return (
<div className='text-muted-foreground text-xs'>
{appliedFilters} filter{appliedFilters !== 1 ? 's' : ''} applied
</div>
)
})()}
</div>
)}
</div>
)
}

View File

@@ -31,7 +31,6 @@ import {
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components'
import type { SubBlockConfig } from '@/blocks/types'
import { DocumentTagEntry } from './components/document-tag-entry/document-tag-entry'
import { KnowledgeTagFilter } from './components/knowledge-tag-filter/knowledge-tag-filter'
import { KnowledgeTagFilters } from './components/knowledge-tag-filters/knowledge-tag-filters'
interface SubBlockProps {
@@ -355,17 +354,6 @@ export function SubBlock({
previewValue={previewValue}
/>
)
case 'knowledge-tag-filter':
return (
<KnowledgeTagFilter
blockId={blockId}
subBlock={config}
disabled={isDisabled}
isPreview={isPreview}
previewValue={previewValue}
isConnecting={isConnecting}
/>
)
case 'knowledge-tag-filters':
return (
<KnowledgeTagFilters

View File

@@ -42,7 +42,6 @@ export type SubBlockType =
| 'channel-selector' // Channel selector for Slack, Discord, etc.
| 'folder-selector' // Folder selector for Gmail, etc.
| 'knowledge-base-selector' // Knowledge base selector
| 'knowledge-tag-filter' // Dynamic tag filter for knowledge bases
| 'knowledge-tag-filters' // Multiple tag filters for knowledge bases
| 'document-selector' // Document selector for knowledge bases
| 'document-tag-entry' // Document tag entry for creating documents