fix(dropdown): simplify & fix tag dropdown for parallel & loop blocks (#634)

* fix(dropdown): simplify & fix tag dropdown for parallel & loop blocks

* fixed build
This commit is contained in:
Waleed Latif
2025-07-08 10:14:14 -07:00
committed by GitHub
parent 3e45d793f1
commit 0f21fbf705
3 changed files with 232 additions and 189 deletions

View File

@@ -1,3 +1,4 @@
import { RepeatIcon, SplitIcon } from 'lucide-react'
import { Card } from '@/components/ui/card'
import { cn } from '@/lib/utils'
import {
@@ -77,8 +78,20 @@ export function ConnectionBlocks({
// Get block configuration for icon and color
const blockConfig = getBlock(connection.type)
const displayName = connection.name // Use the actual block name instead of transforming it
const Icon = blockConfig?.icon
const bgColor = blockConfig?.bgColor || '#6B7280' // Fallback to gray
// Handle special blocks that aren't in the registry (loop and parallel)
let Icon = blockConfig?.icon
let bgColor = blockConfig?.bgColor || '#6B7280' // Fallback to gray
if (!blockConfig) {
if (connection.type === 'loop') {
Icon = RepeatIcon as typeof Icon
bgColor = '#2FB3FF' // Blue color for loop blocks
} else if (connection.type === 'parallel') {
Icon = SplitIcon as typeof Icon
bgColor = '#FEE12B' // Yellow color for parallel blocks
}
}
return (
<Card

View File

@@ -104,10 +104,8 @@ export function useBlockConnections(blockId: string) {
// Get the response format from the subblock store
const responseFormatValue = useSubBlockStore.getState().getValue(sourceId, 'responseFormat')
let responseFormat
// Safely parse response format with proper error handling
responseFormat = parseResponseFormatSafely(responseFormatValue, sourceId)
const responseFormat = parseResponseFormatSafely(responseFormatValue, sourceId)
// Get the default output type from the block's outputs
const defaultOutputs: Field[] = Object.entries(sourceBlock.outputs || {}).map(([key]) => ({
@@ -140,10 +138,8 @@ export function useBlockConnections(blockId: string) {
.getState()
.getValue(edge.source, 'responseFormat')
let responseFormat
// Safely parse response format with proper error handling
responseFormat = parseResponseFormatSafely(responseFormatValue, edge.source)
const responseFormat = parseResponseFormatSafely(responseFormatValue, edge.source)
// Get the default output type from the block's outputs
const defaultOutputs: Field[] = Object.entries(sourceBlock.outputs || {}).map(([key]) => ({

View File

@@ -169,7 +169,39 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
}
const blockConfig = getBlock(sourceBlock.type)
// Handle special blocks that aren't in the registry (loop and parallel)
if (!blockConfig) {
if (sourceBlock.type === 'loop' || sourceBlock.type === 'parallel') {
// Create a mock config with results output for loop/parallel blocks
const mockConfig = {
outputs: {
results: 'array', // These blocks have a results array output
},
}
const blockName = sourceBlock.name || sourceBlock.type
const normalizedBlockName = blockName.replace(/\s+/g, '').toLowerCase()
// Generate output paths for the mock config
const outputPaths = generateOutputPaths(mockConfig.outputs)
const blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
const blockTagGroups: BlockTagGroup[] = [
{
blockName,
blockId: activeSourceBlockId,
blockType: sourceBlock.type,
tags: blockTags,
distance: 0,
},
]
return {
tags: blockTags,
variableInfoMap: {},
blockTagGroups,
}
}
return { tags: [], variableInfoMap: {}, blockTagGroups: [] }
}
@@ -270,28 +302,65 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
{} as Record<string, { type: string; id: string }>
)
// Generate loop tags if current block is in a loop
const loopTags: string[] = []
// Generate loop contextual block group if current block is in a loop
let loopBlockGroup: BlockTagGroup | null = null
const containingLoop = Object.entries(loops).find(([_, loop]) => loop.nodes.includes(blockId))
let containingLoopBlockId: string | null = null
if (containingLoop) {
const [_loopId, loop] = containingLoop
const [loopId, loop] = containingLoop
containingLoopBlockId = loopId
const loopType = loop.loopType || 'for'
loopTags.push('loop.index')
const contextualTags: string[] = ['index']
if (loopType === 'forEach') {
loopTags.push('loop.currentItem')
loopTags.push('loop.items')
contextualTags.push('currentItem')
contextualTags.push('items')
}
// Add the containing loop block's results to the contextual tags
const containingLoopBlock = blocks[loopId]
if (containingLoopBlock) {
const loopBlockName = containingLoopBlock.name || containingLoopBlock.type
const normalizedLoopBlockName = loopBlockName.replace(/\s+/g, '').toLowerCase()
contextualTags.push(`${normalizedLoopBlockName}.results`)
// Create a block group for the loop contextual tags
loopBlockGroup = {
blockName: loopBlockName,
blockId: loopId,
blockType: 'loop',
tags: contextualTags,
distance: 0, // Contextual tags have highest priority
}
}
}
// Generate parallel tags if current block is in parallel
const parallelTags: string[] = []
// Generate parallel contextual block group if current block is in parallel
let parallelBlockGroup: BlockTagGroup | null = null
const containingParallel = Object.entries(parallels || {}).find(([_, parallel]) =>
parallel.nodes.includes(blockId)
)
let containingParallelBlockId: string | null = null
if (containingParallel) {
parallelTags.push('parallel.index')
parallelTags.push('parallel.currentItem')
parallelTags.push('parallel.items')
const [parallelId, parallel] = containingParallel
containingParallelBlockId = parallelId
const contextualTags: string[] = ['index', 'currentItem', 'items']
// Add the containing parallel block's results to the contextual tags
const containingParallelBlock = blocks[parallelId]
if (containingParallelBlock) {
const parallelBlockName = containingParallelBlock.name || containingParallelBlock.type
const normalizedParallelBlockName = parallelBlockName.replace(/\s+/g, '').toLowerCase()
contextualTags.push(`${normalizedParallelBlockName}.results`)
// Create a block group for the parallel contextual tags
parallelBlockGroup = {
blockName: parallelBlockName,
blockId: parallelId,
blockType: 'parallel',
tags: contextualTags,
distance: 0, // Contextual tags have highest priority
}
}
}
// Create block tag groups from accessible blocks
@@ -303,7 +372,43 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
if (!accessibleBlock) continue
const blockConfig = getBlock(accessibleBlock.type)
if (!blockConfig) continue
// Handle special blocks that aren't in the registry (loop and parallel)
if (!blockConfig) {
// For loop and parallel blocks, create a mock config with results output
if (accessibleBlock.type === 'loop' || accessibleBlock.type === 'parallel') {
// Skip this block if it's the containing loop/parallel block - we'll handle it with contextual tags
if (
accessibleBlockId === containingLoopBlockId ||
accessibleBlockId === containingParallelBlockId
) {
continue
}
const mockConfig = {
outputs: {
results: 'array', // These blocks have a results array output
},
}
const blockName = accessibleBlock.name || accessibleBlock.type
const normalizedBlockName = blockName.replace(/\s+/g, '').toLowerCase()
// Generate output paths for the mock config
const outputPaths = generateOutputPaths(mockConfig.outputs)
const blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
blockTagGroups.push({
blockName,
blockId: accessibleBlockId,
blockType: accessibleBlock.type,
tags: blockTags,
distance: blockDistances[accessibleBlockId] || 0,
})
allBlockTags.push(...blockTags)
}
continue
}
const blockName = accessibleBlock.name || accessibleBlock.type
const normalizedBlockName = blockName.replace(/\s+/g, '').toLowerCase()
@@ -328,13 +433,32 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
allBlockTags.push(...blockTags)
}
// Sort block groups by distance (closest first)
// Add contextual block groups at the beginning (they have highest priority)
const finalBlockTagGroups: BlockTagGroup[] = []
if (loopBlockGroup) {
finalBlockTagGroups.push(loopBlockGroup)
}
if (parallelBlockGroup) {
finalBlockTagGroups.push(parallelBlockGroup)
}
// Sort regular block groups by distance (closest first) and add them
blockTagGroups.sort((a, b) => a.distance - b.distance)
finalBlockTagGroups.push(...blockTagGroups)
// Collect all tags for the main tags array
const contextualTags: string[] = []
if (loopBlockGroup) {
contextualTags.push(...loopBlockGroup.tags)
}
if (parallelBlockGroup) {
contextualTags.push(...parallelBlockGroup.tags)
}
return {
tags: [...variableTags, ...loopTags, ...parallelTags, ...allBlockTags],
tags: [...variableTags, ...contextualTags, ...allBlockTags],
variableInfoMap,
blockTagGroups,
blockTagGroups: finalBlockTagGroups,
}
}, [blocks, edges, loops, parallels, blockId, activeSourceBlockId, workflowVariables])
@@ -345,18 +469,12 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
}, [tags, searchTerm])
// Group filtered tags by category
const { variableTags, loopTags, parallelTags, filteredBlockTagGroups } = useMemo(() => {
const { variableTags, filteredBlockTagGroups } = useMemo(() => {
const varTags: string[] = []
const loopTags: string[] = []
const parTags: string[] = []
filteredTags.forEach((tag) => {
if (tag.startsWith('variable.')) {
varTags.push(tag)
} else if (tag.startsWith('loop.')) {
loopTags.push(tag)
} else if (tag.startsWith('parallel.')) {
parTags.push(tag)
}
})
@@ -370,8 +488,6 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
return {
variableTags: varTags,
loopTags: loopTags,
parallelTags: parTags,
filteredBlockTagGroups,
}
}, [filteredTags, blockTagGroups, searchTerm])
@@ -379,8 +495,8 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
// Create ordered tags for keyboard navigation
const orderedTags = useMemo(() => {
const allBlockTags = filteredBlockTagGroups.flatMap((group) => group.tags)
return [...variableTags, ...loopTags, ...parallelTags, ...allBlockTags]
}, [variableTags, loopTags, parallelTags, filteredBlockTagGroups])
return [...variableTags, ...allBlockTags]
}, [variableTags, filteredBlockTagGroups])
// Create efficient tag index lookup map
const tagIndexMap = useMemo(() => {
@@ -393,7 +509,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
// Handle tag selection and text replacement
const handleTagSelect = useCallback(
(tag: string) => {
(tag: string, blockGroup?: BlockTagGroup) => {
const textBeforeCursor = inputValue.slice(0, cursorPosition)
const textAfterCursor = inputValue.slice(cursorPosition)
@@ -401,8 +517,10 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
const lastOpenBracket = textBeforeCursor.lastIndexOf('<')
if (lastOpenBracket === -1) return
// Process variable tags to maintain compatibility
// Process different types of tags
let processedTag = tag
// Handle variable tags
if (tag.startsWith('variable.')) {
const variableName = tag.substring('variable.'.length)
const variableObj = Object.values(variables).find(
@@ -413,6 +531,19 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
processedTag = tag
}
}
// Handle contextual loop/parallel tags
else if (
blockGroup &&
(blockGroup.blockType === 'loop' || blockGroup.blockType === 'parallel')
) {
// Check if this is a contextual tag (without dots) that needs a prefix
if (!tag.includes('.') && ['index', 'currentItem', 'items'].includes(tag)) {
processedTag = `${blockGroup.blockType}.${tag}`
} else {
// It's already a properly formatted tag (like blockname.results)
processedTag = tag
}
}
// Handle existing closing bracket
const nextCloseBracket = textAfterCursor.indexOf('>')
@@ -465,7 +596,12 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
e.preventDefault()
e.stopPropagation()
if (selectedIndex >= 0 && selectedIndex < orderedTags.length) {
handleTagSelect(orderedTags[selectedIndex])
const selectedTag = orderedTags[selectedIndex]
// Find which block group this tag belongs to
const belongsToGroup = filteredBlockTagGroups.find((group) =>
group.tags.includes(selectedTag)
)
handleTagSelect(selectedTag, belongsToGroup)
}
break
case 'Escape':
@@ -479,7 +615,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
window.addEventListener('keydown', handleKeyboardEvent, true)
return () => window.removeEventListener('keydown', handleKeyboardEvent, true)
}
}, [visible, selectedIndex, orderedTags, handleTagSelect, onClose])
}, [visible, selectedIndex, orderedTags, filteredBlockTagGroups, handleTagSelect, onClose])
// Early return if dropdown should not be visible
if (!visible || tags.length === 0 || orderedTags.length === 0) return null
@@ -552,152 +688,21 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
</>
)}
{/* Loop section */}
{loopTags.length > 0 && (
<>
{variableTags.length > 0 && <div className='my-0' />}
<div className='px-2 pt-2.5 pb-0.5 font-medium text-muted-foreground text-xs'>
Loop
</div>
<div className='-mx-1 -px-1'>
{loopTags.map((tag: string) => {
const tagIndex = tagIndexMap.get(tag) ?? -1
const loopProperty = tag.split('.')[1]
// Choose appropriate icon and description based on loop property
let tagIcon = 'L'
let tagDescription = ''
const bgColor = '#8857E6'
if (loopProperty === 'currentItem') {
tagIcon = 'i'
tagDescription = 'Current item'
} else if (loopProperty === 'items') {
tagIcon = 'I'
tagDescription = 'All items'
} else if (loopProperty === 'index') {
tagIcon = '#'
tagDescription = 'Index'
}
return (
<button
key={tag}
className={cn(
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm',
'hover:bg-accent hover:text-accent-foreground',
'focus:bg-accent focus:text-accent-foreground focus:outline-none',
tagIndex === selectedIndex &&
tagIndex >= 0 &&
'bg-accent text-accent-foreground'
)}
onMouseEnter={() => setSelectedIndex(tagIndex >= 0 ? tagIndex : 0)}
onMouseDown={(e) => {
e.preventDefault()
e.stopPropagation()
handleTagSelect(tag)
}}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handleTagSelect(tag)
}}
>
<div
className='flex h-5 w-5 items-center justify-center rounded'
style={{ backgroundColor: bgColor }}
>
<span className='h-3 w-3 font-bold text-white text-xs'>{tagIcon}</span>
</div>
<span className='flex-1 truncate'>{tag}</span>
<span className='ml-auto text-muted-foreground text-xs'>
{tagDescription}
</span>
</button>
)
})}
</div>
</>
)}
{/* Parallel section */}
{parallelTags.length > 0 && (
<>
{loopTags.length > 0 && <div className='my-0' />}
<div className='px-2 pt-2.5 pb-0.5 font-medium text-muted-foreground text-xs'>
Parallel
</div>
<div className='-mx-1 -px-1'>
{parallelTags.map((tag: string) => {
const tagIndex = tagIndexMap.get(tag) ?? -1
const parallelProperty = tag.split('.')[1]
// Choose appropriate icon and description based on parallel property
let tagIcon = 'P'
let tagDescription = ''
const bgColor = '#FF5757'
if (parallelProperty === 'currentItem') {
tagIcon = 'i'
tagDescription = 'Current item'
} else if (parallelProperty === 'items') {
tagIcon = 'I'
tagDescription = 'All items'
} else if (parallelProperty === 'index') {
tagIcon = '#'
tagDescription = 'Index'
}
return (
<button
key={tag}
className={cn(
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm',
'hover:bg-accent hover:text-accent-foreground',
'focus:bg-accent focus:text-accent-foreground focus:outline-none',
tagIndex === selectedIndex &&
tagIndex >= 0 &&
'bg-accent text-accent-foreground'
)}
onMouseEnter={() => setSelectedIndex(tagIndex >= 0 ? tagIndex : 0)}
onMouseDown={(e) => {
e.preventDefault()
e.stopPropagation()
handleTagSelect(tag)
}}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handleTagSelect(tag)
}}
>
<div
className='flex h-5 w-5 items-center justify-center rounded'
style={{ backgroundColor: bgColor }}
>
<span className='h-3 w-3 font-bold text-white text-xs'>{tagIcon}</span>
</div>
<span className='flex-1 truncate'>{tag}</span>
<span className='ml-auto text-muted-foreground text-xs'>
{tagDescription}
</span>
</button>
)
})}
</div>
</>
)}
{/* Block sections */}
{filteredBlockTagGroups.length > 0 && (
<>
{(variableTags.length > 0 || loopTags.length > 0 || parallelTags.length > 0) && (
<div className='my-0' />
)}
{variableTags.length > 0 && <div className='my-0' />}
{filteredBlockTagGroups.map((group) => {
// Get block color from configuration
const blockConfig = getBlock(group.blockType)
const blockColor = blockConfig?.bgColor || '#2F55FF'
let blockColor = blockConfig?.bgColor || '#2F55FF'
// Handle special colors for loop and parallel blocks
if (group.blockType === 'loop') {
blockColor = '#8857E6' // Purple color for loop blocks
} else if (group.blockType === 'parallel') {
blockColor = '#FF5757' // Red color for parallel blocks
}
return (
<div key={group.blockId}>
@@ -707,11 +712,37 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
<div>
{group.tags.map((tag: string) => {
const tagIndex = tagIndexMap.get(tag) ?? -1
// Extract path after block name (e.g., "field" from "blockname.field")
// For root reference blocks, show the block name instead of empty path
const tagParts = tag.split('.')
const path = tagParts.slice(1).join('.')
const displayText = path || group.blockName
// Handle display text based on tag type
let displayText: string
let tagDescription = ''
let tagIcon = group.blockName.charAt(0).toUpperCase()
if (
(group.blockType === 'loop' || group.blockType === 'parallel') &&
!tag.includes('.')
) {
// Contextual tags like 'index', 'currentItem', 'items'
displayText = tag
if (tag === 'index') {
tagIcon = '#'
tagDescription = 'Index'
} else if (tag === 'currentItem') {
tagIcon = 'i'
tagDescription = 'Current item'
} else if (tag === 'items') {
tagIcon = 'I'
tagDescription = 'All items'
}
} else {
// Regular block output tags like 'blockname.field' or 'blockname.results'
const tagParts = tag.split('.')
const path = tagParts.slice(1).join('.')
displayText = path || group.blockName
if (path === 'results') {
tagDescription = 'Results array'
}
}
return (
<button
@@ -728,12 +759,12 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
onMouseDown={(e) => {
e.preventDefault()
e.stopPropagation()
handleTagSelect(tag)
handleTagSelect(tag, group)
}}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handleTagSelect(tag)
handleTagSelect(tag, group)
}}
>
<div
@@ -741,12 +772,15 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
style={{ backgroundColor: blockColor }}
>
<span className='h-3 w-3 font-bold text-white text-xs'>
{group.blockName.charAt(0).toUpperCase()}
{tagIcon}
</span>
</div>
<span className='max-w-[calc(100%-32px)] truncate'>
{displayText}
</span>
<span className='flex-1 truncate'>{displayText}</span>
{tagDescription && (
<span className='ml-auto text-muted-foreground text-xs'>
{tagDescription}
</span>
)}
</button>
)
})}