ConditionBuilder.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  1. import { Plus, Trash2, GripVertical } from 'lucide-react'
  2. export interface Condition {
  3. field: string
  4. operator: string
  5. value: string
  6. }
  7. export interface AvailableField {
  8. path: string
  9. type: string
  10. nodeName: string
  11. displayName?: string
  12. }
  13. interface ConditionBuilderProps {
  14. conditions: Condition[]
  15. combineWith: 'and' | 'or'
  16. onChange: (conditions: Condition[], combineWith: 'and' | 'or') => void
  17. availableFields?: AvailableField[]
  18. }
  19. const OPERATORS = [
  20. { value: 'equals', label: 'Equals', description: '==' },
  21. { value: 'not_equals', label: 'Not Equals', description: '!=' },
  22. { value: 'greater_than', label: 'Greater Than', description: '>' },
  23. { value: 'less_than', label: 'Less Than', description: '<' },
  24. { value: 'greater_than_or_equal', label: 'Greater Than or Equal', description: '>=' },
  25. { value: 'less_than_or_equal', label: 'Less Than or Equal', description: '<=' },
  26. { value: 'contains', label: 'Contains', description: 'includes' },
  27. { value: 'not_contains', label: 'Not Contains', description: '!includes' },
  28. { value: 'starts_with', label: 'Starts With', description: 'startsWith' },
  29. { value: 'ends_with', label: 'Ends With', description: 'endsWith' },
  30. { value: 'is_empty', label: 'Is Empty', description: 'null/empty' },
  31. { value: 'is_not_empty', label: 'Is Not Empty', description: '!empty' },
  32. { value: 'is_true', label: 'Is True', description: '=== true' },
  33. { value: 'is_false', label: 'Is False', description: '=== false' },
  34. { value: 'is_null', label: 'Is Null', description: '=== null' },
  35. { value: 'is_not_null', label: 'Is Not Null', description: '!== null' },
  36. { value: 'exists', label: 'Exists', description: '!== undefined' },
  37. { value: 'regex', label: 'Matches Regex', description: '/pattern/' },
  38. ]
  39. // Operators that don't need a value input
  40. const VALUE_LESS_OPERATORS = [
  41. 'is_empty',
  42. 'is_not_empty',
  43. 'is_true',
  44. 'is_false',
  45. 'is_null',
  46. 'is_not_null',
  47. 'exists',
  48. ]
  49. export function ConditionBuilder({
  50. conditions,
  51. combineWith,
  52. onChange,
  53. availableFields = [],
  54. }: ConditionBuilderProps) {
  55. const addCondition = () => {
  56. const newCondition: Condition = {
  57. field: '',
  58. operator: 'equals',
  59. value: '',
  60. }
  61. onChange([...conditions, newCondition], combineWith)
  62. }
  63. const updateCondition = (index: number, updates: Partial<Condition>) => {
  64. // Strip {{...}} template syntax from field paths - IF condition uses raw paths
  65. if (updates.field) {
  66. const match = updates.field.match(/^\{\{(.+)\}\}$/)
  67. if (match) {
  68. updates.field = match[1]
  69. }
  70. }
  71. const updated = conditions.map((c, i) =>
  72. i === index ? { ...c, ...updates } : c
  73. )
  74. onChange(updated, combineWith)
  75. }
  76. const removeCondition = (index: number) => {
  77. onChange(
  78. conditions.filter((_, i) => i !== index),
  79. combineWith
  80. )
  81. }
  82. const isValueLessOperator = (operator: string) =>
  83. VALUE_LESS_OPERATORS.includes(operator)
  84. return (
  85. <div className="space-y-4">
  86. {/* Combine selector */}
  87. <div className="flex items-center gap-2">
  88. <span className="text-sm text-gray-600 dark:text-gray-400">When</span>
  89. <select
  90. value={combineWith}
  91. onChange={(e) => onChange(conditions, e.target.value as 'and' | 'or')}
  92. className="px-3 py-1.5 border border-gray-200 dark:border-slate-600 rounded-lg text-sm font-medium bg-white dark:bg-slate-900 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
  93. >
  94. <option value="and">ALL conditions match (AND)</option>
  95. <option value="or">ANY condition matches (OR)</option>
  96. </select>
  97. </div>
  98. {/* Conditions list */}
  99. <div className="space-y-3">
  100. {conditions.length === 0 ? (
  101. <div className="text-sm text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-slate-900 p-4 rounded-lg text-center">
  102. No conditions defined. Add a condition to enable branching.
  103. <br />
  104. <span className="text-xs text-gray-400 dark:text-gray-500">
  105. Without conditions, the IF node will always output to the TRUE branch.
  106. </span>
  107. </div>
  108. ) : (
  109. conditions.map((condition, index) => (
  110. <div
  111. key={index}
  112. className="flex items-start gap-2 p-3 bg-gray-50 dark:bg-slate-900 rounded-lg border border-gray-200 dark:border-slate-700"
  113. >
  114. {/* Drag handle (placeholder for future drag-drop) */}
  115. <div className="mt-2 text-gray-400 dark:text-gray-500 cursor-grab">
  116. <GripVertical className="w-4 h-4" />
  117. </div>
  118. <div className="flex-1 space-y-2">
  119. {/* Field input with autocomplete */}
  120. <div>
  121. <label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
  122. Field Path
  123. </label>
  124. <div className="relative">
  125. <input
  126. type="text"
  127. value={condition.field}
  128. onChange={(e) =>
  129. updateCondition(index, { field: e.target.value })
  130. }
  131. placeholder="e.g., data.status or data.user.email"
  132. className="w-full px-3 py-2 border border-gray-200 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
  133. list={`fields-${index}`}
  134. />
  135. {availableFields.length > 0 && (
  136. <datalist id={`fields-${index}`}>
  137. {availableFields.map((field) => (
  138. <option
  139. key={field.path}
  140. value={field.path}
  141. label={`${field.displayName || field.path} (${field.type}) from ${field.nodeName}`}
  142. />
  143. ))}
  144. </datalist>
  145. )}
  146. </div>
  147. </div>
  148. {/* Operator and Value row */}
  149. <div className="flex gap-2">
  150. <div className="flex-1">
  151. <label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
  152. Operator
  153. </label>
  154. <select
  155. value={condition.operator}
  156. onChange={(e) =>
  157. updateCondition(index, { operator: e.target.value })
  158. }
  159. className="w-full px-3 py-2 border border-gray-200 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
  160. >
  161. {OPERATORS.map((op) => (
  162. <option key={op.value} value={op.value}>
  163. {op.label} ({op.description})
  164. </option>
  165. ))}
  166. </select>
  167. </div>
  168. {!isValueLessOperator(condition.operator) && (
  169. <div className="flex-1">
  170. <label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
  171. Value
  172. </label>
  173. <input
  174. type="text"
  175. value={condition.value}
  176. onChange={(e) =>
  177. updateCondition(index, { value: e.target.value })
  178. }
  179. placeholder="Value to compare"
  180. className="w-full px-3 py-2 border border-gray-200 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
  181. />
  182. </div>
  183. )}
  184. </div>
  185. </div>
  186. {/* Remove button */}
  187. <button
  188. onClick={() => removeCondition(index)}
  189. className="mt-2 p-1.5 text-red-500 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors"
  190. title="Remove condition"
  191. >
  192. <Trash2 className="w-4 h-4" />
  193. </button>
  194. </div>
  195. ))
  196. )}
  197. </div>
  198. {/* Add condition button */}
  199. <button
  200. onClick={addCondition}
  201. className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-primary-600 dark:text-primary-400 bg-primary-50 dark:bg-primary-900/20 hover:bg-primary-100 dark:hover:bg-primary-900/30 rounded-lg transition-colors w-full justify-center"
  202. >
  203. <Plus className="w-4 h-4" />
  204. Add Condition
  205. </button>
  206. {/* Preview section */}
  207. {conditions.length > 0 && (
  208. <div className="mt-4 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
  209. <div className="text-xs font-medium text-blue-800 dark:text-blue-300 mb-1">
  210. Condition Preview
  211. </div>
  212. <code className="text-xs text-blue-700 dark:text-blue-400 break-all">
  213. {conditions
  214. .map((c) => {
  215. const op = OPERATORS.find((o) => o.value === c.operator)
  216. if (isValueLessOperator(c.operator)) {
  217. return `${c.field} ${op?.description || c.operator}`
  218. }
  219. // Don't quote numeric values for numeric operators
  220. const numericOperators = ['greater_than', 'less_than', 'greater_than_or_equal', 'less_than_or_equal']
  221. const isNumeric = numericOperators.includes(c.operator) && !isNaN(Number(c.value))
  222. return `${c.field} ${op?.description || c.operator} ${isNumeric ? c.value : `"${c.value}"`}`
  223. })
  224. .join(combineWith === 'and' ? ' && ' : ' || ')}
  225. </code>
  226. </div>
  227. )}
  228. </div>
  229. )
  230. }