AvailableDataPanel.tsx 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. import { useState } from 'react'
  2. import { ChevronRight, ChevronDown, GripVertical, Copy, Check } from 'lucide-react'
  3. export interface FieldInfo {
  4. path: string
  5. type: string
  6. nodeName: string
  7. nodeId: string
  8. displayName?: string
  9. children?: FieldInfo[]
  10. isDirectUpstream?: boolean // true if this is an immediate predecessor node
  11. targetInput?: string // the connection's target_input name (for direct upstream)
  12. }
  13. interface AvailableDataPanelProps {
  14. fields: FieldInfo[]
  15. onInsertExpression?: (expression: string) => void
  16. title?: string
  17. }
  18. const TYPE_COLORS: Record<string, string> = {
  19. string: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 border-green-200 dark:border-green-800',
  20. number: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 border-blue-200 dark:border-blue-800',
  21. boolean: 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400 border-purple-200 dark:border-purple-800',
  22. object: 'bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-400 border-orange-200 dark:border-orange-800',
  23. array: 'bg-pink-100 dark:bg-pink-900/30 text-pink-700 dark:text-pink-400 border-pink-200 dark:border-pink-800',
  24. any: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-400 border-gray-200 dark:border-gray-700',
  25. }
  26. const TYPE_ICONS: Record<string, string> = {
  27. string: '"A"',
  28. number: '123',
  29. boolean: '?',
  30. object: '{ }',
  31. array: '[ ]',
  32. any: '*',
  33. }
  34. interface FieldItemProps {
  35. field: FieldInfo
  36. depth: number
  37. onInsertExpression?: (expression: string) => void
  38. }
  39. function FieldItem({ field, depth, onInsertExpression }: FieldItemProps) {
  40. const [isExpanded, setIsExpanded] = useState(false)
  41. const [copied, setCopied] = useState(false)
  42. const hasChildren = field.children && field.children.length > 0
  43. // Both direct and non-direct upstream use {{}} braces for evaluation
  44. // Direct upstream: path prefixed with targetInput (evaluated against input)
  45. // Non-direct upstream: $node accessor to reference specific node output
  46. const expression = field.isDirectUpstream
  47. ? field.targetInput
  48. ? `{{${field.targetInput}.${field.path}}}` // Direct upstream with targetInput prefix
  49. : `{{${field.path}}}` // Fallback (rare case)
  50. : `{{$node["${field.nodeName}"].${field.path}}}`
  51. const handleCopy = () => {
  52. navigator.clipboard.writeText(expression)
  53. setCopied(true)
  54. setTimeout(() => setCopied(false), 2000)
  55. }
  56. const handleInsert = () => {
  57. onInsertExpression?.(expression)
  58. }
  59. const handleDragStart = (e: React.DragEvent) => {
  60. e.dataTransfer.setData('text/plain', expression)
  61. e.dataTransfer.effectAllowed = 'copy'
  62. }
  63. return (
  64. <div>
  65. <div
  66. className={`flex items-center gap-1 py-1.5 px-2 hover:bg-gray-50 dark:hover:bg-slate-700 rounded cursor-pointer group ${
  67. depth > 0 ? 'ml-' + Math.min(depth * 4, 12) : ''
  68. }`}
  69. style={{ marginLeft: depth * 16 }}
  70. draggable
  71. onDragStart={handleDragStart}
  72. >
  73. {/* Expand/collapse toggle */}
  74. {hasChildren ? (
  75. <button
  76. onClick={() => setIsExpanded(!isExpanded)}
  77. className="p-0.5 hover:bg-gray-200 dark:hover:bg-slate-600 rounded"
  78. >
  79. {isExpanded ? (
  80. <ChevronDown className="w-3 h-3 text-gray-500 dark:text-gray-400" />
  81. ) : (
  82. <ChevronRight className="w-3 h-3 text-gray-500 dark:text-gray-400" />
  83. )}
  84. </button>
  85. ) : (
  86. <span className="w-4" />
  87. )}
  88. {/* Drag handle */}
  89. <GripVertical className="w-3 h-3 text-gray-300 dark:text-gray-600 group-hover:text-gray-500 dark:group-hover:text-gray-400 cursor-grab" />
  90. {/* Type badge */}
  91. <span
  92. className={`px-1 py-0.5 rounded text-[9px] font-mono border ${
  93. TYPE_COLORS[field.type] || TYPE_COLORS.any
  94. }`}
  95. title={field.type}
  96. >
  97. {TYPE_ICONS[field.type] || TYPE_ICONS.any}
  98. </span>
  99. {/* Field name */}
  100. <span
  101. className="flex-1 text-sm text-gray-800 dark:text-gray-200 truncate"
  102. onClick={handleInsert}
  103. title={`Click to insert: ${expression}`}
  104. >
  105. {field.displayName || field.path.split('.').pop()}
  106. </span>
  107. {/* Copy button */}
  108. <button
  109. onClick={handleCopy}
  110. className="p-1 opacity-0 group-hover:opacity-100 hover:bg-gray-200 dark:hover:bg-slate-600 rounded transition-opacity"
  111. title="Copy expression"
  112. >
  113. {copied ? (
  114. <Check className="w-3 h-3 text-green-500" />
  115. ) : (
  116. <Copy className="w-3 h-3 text-gray-400 dark:text-gray-500" />
  117. )}
  118. </button>
  119. </div>
  120. {/* Children */}
  121. {hasChildren && isExpanded && (
  122. <div>
  123. {field.children!.map((child, index) => (
  124. <FieldItem
  125. key={`${child.path}-${index}`}
  126. field={child}
  127. depth={depth + 1}
  128. onInsertExpression={onInsertExpression}
  129. />
  130. ))}
  131. </div>
  132. )}
  133. </div>
  134. )
  135. }
  136. interface NodeGroupProps {
  137. nodeName: string
  138. fields: FieldInfo[]
  139. onInsertExpression?: (expression: string) => void
  140. }
  141. function NodeGroup({ nodeName, fields, onInsertExpression }: NodeGroupProps) {
  142. const [isExpanded, setIsExpanded] = useState(true)
  143. return (
  144. <div className="border border-gray-200 dark:border-slate-700 rounded-lg overflow-hidden mb-2">
  145. <button
  146. onClick={() => setIsExpanded(!isExpanded)}
  147. className="w-full flex items-center gap-2 px-3 py-2 bg-gray-50 dark:bg-slate-800 hover:bg-gray-100 dark:hover:bg-slate-700 text-left"
  148. >
  149. {isExpanded ? (
  150. <ChevronDown className="w-4 h-4 text-gray-500 dark:text-gray-400" />
  151. ) : (
  152. <ChevronRight className="w-4 h-4 text-gray-500 dark:text-gray-400" />
  153. )}
  154. <span className="text-sm font-medium text-gray-700 dark:text-gray-200">{nodeName}</span>
  155. <span className="text-xs text-gray-400 dark:text-gray-500 ml-auto">{fields.length} fields</span>
  156. </button>
  157. {isExpanded && (
  158. <div className="py-1 bg-white dark:bg-slate-900">
  159. {fields.map((field, index) => (
  160. <FieldItem
  161. key={`${field.path}-${index}`}
  162. field={field}
  163. depth={0}
  164. onInsertExpression={onInsertExpression}
  165. />
  166. ))}
  167. </div>
  168. )}
  169. </div>
  170. )
  171. }
  172. export function AvailableDataPanel({
  173. fields,
  174. onInsertExpression,
  175. title = 'Available Data',
  176. }: AvailableDataPanelProps) {
  177. // Group fields by node
  178. const groupedByNode = fields.reduce((acc, field) => {
  179. const key = field.nodeId
  180. if (!acc[key]) {
  181. acc[key] = {
  182. nodeName: field.nodeName,
  183. nodeId: field.nodeId,
  184. fields: [],
  185. }
  186. }
  187. acc[key].fields.push(field)
  188. return acc
  189. }, {} as Record<string, { nodeName: string; nodeId: string; fields: FieldInfo[] }>)
  190. const nodeGroups = Object.values(groupedByNode)
  191. if (fields.length === 0) {
  192. return (
  193. <div className="p-4 text-center text-sm text-gray-500 dark:text-gray-400">
  194. <div className="mb-2">No upstream data available</div>
  195. <div className="text-xs text-gray-400 dark:text-gray-500">
  196. Connect nodes to see their output fields here
  197. </div>
  198. </div>
  199. )
  200. }
  201. return (
  202. <div className="h-full flex flex-col">
  203. <div className="px-3 py-2 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-800">
  204. <h3 className="text-sm font-medium text-gray-700 dark:text-gray-200">{title}</h3>
  205. <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
  206. Drag or click to insert expressions
  207. </p>
  208. </div>
  209. <div className="flex-1 overflow-auto p-2 bg-white dark:bg-slate-900">
  210. {nodeGroups.map((group) => (
  211. <NodeGroup
  212. key={group.nodeId}
  213. nodeName={group.nodeName}
  214. fields={group.fields}
  215. onInsertExpression={onInsertExpression}
  216. />
  217. ))}
  218. </div>
  219. {/* Quick reference */}
  220. <div className="px-3 py-2 border-t border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-800 text-xs text-gray-500 dark:text-gray-400">
  221. <div className="font-medium mb-1">Expression Syntax:</div>
  222. <code className="text-[10px] bg-gray-100 dark:bg-slate-700 px-1 rounded">
  223. {'{{$node["Name"].path}}'}
  224. </code>
  225. </div>
  226. </div>
  227. )
  228. }