| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247 |
- import { Plus, Trash2, GripVertical } from 'lucide-react'
- export interface Condition {
- field: string
- operator: string
- value: string
- }
- export interface AvailableField {
- path: string
- type: string
- nodeName: string
- displayName?: string
- }
- interface ConditionBuilderProps {
- conditions: Condition[]
- combineWith: 'and' | 'or'
- onChange: (conditions: Condition[], combineWith: 'and' | 'or') => void
- availableFields?: AvailableField[]
- }
- const OPERATORS = [
- { value: 'equals', label: 'Equals', description: '==' },
- { value: 'not_equals', label: 'Not Equals', description: '!=' },
- { value: 'greater_than', label: 'Greater Than', description: '>' },
- { value: 'less_than', label: 'Less Than', description: '<' },
- { value: 'greater_than_or_equal', label: 'Greater Than or Equal', description: '>=' },
- { value: 'less_than_or_equal', label: 'Less Than or Equal', description: '<=' },
- { value: 'contains', label: 'Contains', description: 'includes' },
- { value: 'not_contains', label: 'Not Contains', description: '!includes' },
- { value: 'starts_with', label: 'Starts With', description: 'startsWith' },
- { value: 'ends_with', label: 'Ends With', description: 'endsWith' },
- { value: 'is_empty', label: 'Is Empty', description: 'null/empty' },
- { value: 'is_not_empty', label: 'Is Not Empty', description: '!empty' },
- { value: 'is_true', label: 'Is True', description: '=== true' },
- { value: 'is_false', label: 'Is False', description: '=== false' },
- { value: 'is_null', label: 'Is Null', description: '=== null' },
- { value: 'is_not_null', label: 'Is Not Null', description: '!== null' },
- { value: 'exists', label: 'Exists', description: '!== undefined' },
- { value: 'regex', label: 'Matches Regex', description: '/pattern/' },
- ]
- // Operators that don't need a value input
- const VALUE_LESS_OPERATORS = [
- 'is_empty',
- 'is_not_empty',
- 'is_true',
- 'is_false',
- 'is_null',
- 'is_not_null',
- 'exists',
- ]
- export function ConditionBuilder({
- conditions,
- combineWith,
- onChange,
- availableFields = [],
- }: ConditionBuilderProps) {
- const addCondition = () => {
- const newCondition: Condition = {
- field: '',
- operator: 'equals',
- value: '',
- }
- onChange([...conditions, newCondition], combineWith)
- }
- const updateCondition = (index: number, updates: Partial<Condition>) => {
- // Strip {{...}} template syntax from field paths - IF condition uses raw paths
- if (updates.field) {
- const match = updates.field.match(/^\{\{(.+)\}\}$/)
- if (match) {
- updates.field = match[1]
- }
- }
- const updated = conditions.map((c, i) =>
- i === index ? { ...c, ...updates } : c
- )
- onChange(updated, combineWith)
- }
- const removeCondition = (index: number) => {
- onChange(
- conditions.filter((_, i) => i !== index),
- combineWith
- )
- }
- const isValueLessOperator = (operator: string) =>
- VALUE_LESS_OPERATORS.includes(operator)
- return (
- <div className="space-y-4">
- {/* Combine selector */}
- <div className="flex items-center gap-2">
- <span className="text-sm text-gray-600 dark:text-gray-400">When</span>
- <select
- value={combineWith}
- onChange={(e) => onChange(conditions, e.target.value as 'and' | 'or')}
- 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"
- >
- <option value="and">ALL conditions match (AND)</option>
- <option value="or">ANY condition matches (OR)</option>
- </select>
- </div>
- {/* Conditions list */}
- <div className="space-y-3">
- {conditions.length === 0 ? (
- <div className="text-sm text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-slate-900 p-4 rounded-lg text-center">
- No conditions defined. Add a condition to enable branching.
- <br />
- <span className="text-xs text-gray-400 dark:text-gray-500">
- Without conditions, the IF node will always output to the TRUE branch.
- </span>
- </div>
- ) : (
- conditions.map((condition, index) => (
- <div
- key={index}
- 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"
- >
- {/* Drag handle (placeholder for future drag-drop) */}
- <div className="mt-2 text-gray-400 dark:text-gray-500 cursor-grab">
- <GripVertical className="w-4 h-4" />
- </div>
- <div className="flex-1 space-y-2">
- {/* Field input with autocomplete */}
- <div>
- <label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
- Field Path
- </label>
- <div className="relative">
- <input
- type="text"
- value={condition.field}
- onChange={(e) =>
- updateCondition(index, { field: e.target.value })
- }
- placeholder="e.g., data.status or data.user.email"
- 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"
- list={`fields-${index}`}
- />
- {availableFields.length > 0 && (
- <datalist id={`fields-${index}`}>
- {availableFields.map((field) => (
- <option
- key={field.path}
- value={field.path}
- label={`${field.displayName || field.path} (${field.type}) from ${field.nodeName}`}
- />
- ))}
- </datalist>
- )}
- </div>
- </div>
- {/* Operator and Value row */}
- <div className="flex gap-2">
- <div className="flex-1">
- <label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
- Operator
- </label>
- <select
- value={condition.operator}
- onChange={(e) =>
- updateCondition(index, { operator: e.target.value })
- }
- 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"
- >
- {OPERATORS.map((op) => (
- <option key={op.value} value={op.value}>
- {op.label} ({op.description})
- </option>
- ))}
- </select>
- </div>
- {!isValueLessOperator(condition.operator) && (
- <div className="flex-1">
- <label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
- Value
- </label>
- <input
- type="text"
- value={condition.value}
- onChange={(e) =>
- updateCondition(index, { value: e.target.value })
- }
- placeholder="Value to compare"
- 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"
- />
- </div>
- )}
- </div>
- </div>
- {/* Remove button */}
- <button
- onClick={() => removeCondition(index)}
- 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"
- title="Remove condition"
- >
- <Trash2 className="w-4 h-4" />
- </button>
- </div>
- ))
- )}
- </div>
- {/* Add condition button */}
- <button
- onClick={addCondition}
- 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"
- >
- <Plus className="w-4 h-4" />
- Add Condition
- </button>
- {/* Preview section */}
- {conditions.length > 0 && (
- <div className="mt-4 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
- <div className="text-xs font-medium text-blue-800 dark:text-blue-300 mb-1">
- Condition Preview
- </div>
- <code className="text-xs text-blue-700 dark:text-blue-400 break-all">
- {conditions
- .map((c) => {
- const op = OPERATORS.find((o) => o.value === c.operator)
- if (isValueLessOperator(c.operator)) {
- return `${c.field} ${op?.description || c.operator}`
- }
- // Don't quote numeric values for numeric operators
- const numericOperators = ['greater_than', 'less_than', 'greater_than_or_equal', 'less_than_or_equal']
- const isNumeric = numericOperators.includes(c.operator) && !isNaN(Number(c.value))
- return `${c.field} ${op?.description || c.operator} ${isNumeric ? c.value : `"${c.value}"`}`
- })
- .join(combineWith === 'and' ? ' && ' : ' || ')}
- </code>
- </div>
- )}
- </div>
- )
- }
|