@startsimpli/funnels 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +9 -31
- package/src/api/README.md +507 -0
- package/src/api/adapter.ts +106 -0
- package/src/api/client.test.ts +640 -0
- package/src/api/client.ts +385 -0
- package/src/api/default-adapter.ts +243 -0
- package/src/api/index.ts +24 -0
- package/src/components/FilterRuleEditor/ARCHITECTURE.md +354 -0
- package/src/components/FilterRuleEditor/FieldSelector.tsx +91 -0
- package/src/components/FilterRuleEditor/FilterRuleEditor.stories.tsx +462 -0
- package/src/components/FilterRuleEditor/FilterRuleEditor.test.tsx +520 -0
- package/src/components/FilterRuleEditor/FilterRuleEditor.tsx +225 -0
- package/src/components/FilterRuleEditor/LogicToggle.tsx +64 -0
- package/src/components/FilterRuleEditor/OperatorSelector.tsx +75 -0
- package/src/components/FilterRuleEditor/README.md +291 -0
- package/src/components/FilterRuleEditor/RuleRow.tsx +246 -0
- package/src/components/FilterRuleEditor/ValueInputs/BooleanValueInput.tsx +54 -0
- package/src/components/FilterRuleEditor/ValueInputs/ChoiceValueInput.tsx +83 -0
- package/src/components/FilterRuleEditor/ValueInputs/DateValueInput.tsx +70 -0
- package/src/components/FilterRuleEditor/ValueInputs/MultiChoiceValueInput.tsx +132 -0
- package/src/components/FilterRuleEditor/ValueInputs/NumberValueInput.tsx +73 -0
- package/src/components/FilterRuleEditor/ValueInputs/TextValueInput.tsx +50 -0
- package/src/components/FilterRuleEditor/ValueInputs/index.ts +12 -0
- package/src/components/FilterRuleEditor/constants.ts +64 -0
- package/src/components/FilterRuleEditor/index.ts +14 -0
- package/src/components/FunnelCard/DESIGN.md +447 -0
- package/src/components/FunnelCard/FunnelCard.stories.tsx +484 -0
- package/src/components/FunnelCard/FunnelCard.test.ts +257 -0
- package/src/components/FunnelCard/FunnelCard.test.tsx +336 -0
- package/src/components/FunnelCard/FunnelCard.tsx +204 -0
- package/src/components/FunnelCard/FunnelStats.tsx +68 -0
- package/src/components/FunnelCard/IMPLEMENTATION_SUMMARY.md +505 -0
- package/src/components/FunnelCard/INSTALLATION.md +304 -0
- package/src/components/FunnelCard/MatchBar.tsx +49 -0
- package/src/components/FunnelCard/README.md +294 -0
- package/src/components/FunnelCard/StageIndicator.tsx +62 -0
- package/src/components/FunnelCard/StatusBadge.tsx +52 -0
- package/src/components/FunnelCard/index.ts +14 -0
- package/src/components/FunnelPreview/EntityCard.tsx +72 -0
- package/src/components/FunnelPreview/FunnelPreview.stories.tsx +227 -0
- package/src/components/FunnelPreview/FunnelPreview.test.tsx +316 -0
- package/src/components/FunnelPreview/FunnelPreview.tsx +249 -0
- package/src/components/FunnelPreview/LoadingPreview.tsx +60 -0
- package/src/components/FunnelPreview/PreviewStats.tsx +78 -0
- package/src/components/FunnelPreview/README.md +337 -0
- package/src/components/FunnelPreview/StageBreakdown.tsx +94 -0
- package/src/components/FunnelPreview/example.tsx +286 -0
- package/src/components/FunnelPreview/index.ts +14 -0
- package/src/components/FunnelRunHistory/COMPONENT_SUMMARY.md +246 -0
- package/src/components/FunnelRunHistory/FunnelRunHistory.stories.tsx +272 -0
- package/src/components/FunnelRunHistory/FunnelRunHistory.test.tsx +323 -0
- package/src/components/FunnelRunHistory/FunnelRunHistory.tsx +329 -0
- package/src/components/FunnelRunHistory/README.md +325 -0
- package/src/components/FunnelRunHistory/RunActions.tsx +168 -0
- package/src/components/FunnelRunHistory/RunDetailsModal.tsx +221 -0
- package/src/components/FunnelRunHistory/RunFilters.tsx +128 -0
- package/src/components/FunnelRunHistory/RunRow.tsx +122 -0
- package/src/components/FunnelRunHistory/RunStatusBadge.tsx +75 -0
- package/src/components/FunnelRunHistory/StageBreakdownList.tsx +110 -0
- package/src/components/FunnelRunHistory/index.ts +51 -0
- package/src/components/FunnelRunHistory/types.ts +40 -0
- package/src/components/FunnelRunHistory/utils.test.ts +126 -0
- package/src/components/FunnelRunHistory/utils.ts +100 -0
- package/src/components/FunnelStageBuilder/AddStageButton.tsx +52 -0
- package/src/components/FunnelStageBuilder/FunnelStageBuilder.css +413 -0
- package/src/components/FunnelStageBuilder/FunnelStageBuilder.stories.tsx +312 -0
- package/src/components/FunnelStageBuilder/FunnelStageBuilder.test.tsx +304 -0
- package/src/components/FunnelStageBuilder/FunnelStageBuilder.tsx +321 -0
- package/src/components/FunnelStageBuilder/README.md +341 -0
- package/src/components/FunnelStageBuilder/StageActions.test.tsx +205 -0
- package/src/components/FunnelStageBuilder/StageActions.tsx +126 -0
- package/src/components/FunnelStageBuilder/StageCard.tsx +202 -0
- package/src/components/FunnelStageBuilder/StageForm.tsx +262 -0
- package/src/components/FunnelStageBuilder/TagInput.test.tsx +178 -0
- package/src/components/FunnelStageBuilder/TagInput.tsx +129 -0
- package/src/components/FunnelStageBuilder/index.ts +21 -0
- package/src/components/FunnelVisualFlow/FlowLegend.tsx +77 -0
- package/{dist/components/index.css → src/components/FunnelVisualFlow/FunnelVisualFlow.css} +89 -13
- package/src/components/FunnelVisualFlow/FunnelVisualFlow.stories.tsx +254 -0
- package/src/components/FunnelVisualFlow/FunnelVisualFlow.test.tsx +208 -0
- package/src/components/FunnelVisualFlow/FunnelVisualFlow.tsx +229 -0
- package/src/components/FunnelVisualFlow/README.md +323 -0
- package/src/components/FunnelVisualFlow/StageNode.tsx +188 -0
- package/src/components/FunnelVisualFlow/example.tsx +227 -0
- package/src/components/FunnelVisualFlow/index.ts +10 -0
- package/src/components/index.ts +102 -0
- package/src/core/README.md +307 -0
- package/src/core/engine.test.ts +1087 -0
- package/src/core/engine.ts +329 -0
- package/src/core/evaluator.example.ts +353 -0
- package/src/core/evaluator.test.ts +639 -0
- package/src/core/evaluator.ts +261 -0
- package/src/core/field-resolver.example.ts +175 -0
- package/src/core/field-resolver.test.ts +541 -0
- package/src/core/field-resolver.ts +247 -0
- package/src/core/index.ts +34 -0
- package/src/core/operators.test.ts +539 -0
- package/src/core/operators.ts +241 -0
- package/src/hooks/index.ts +5 -0
- package/src/hooks/useDebouncedValue.ts +28 -0
- package/src/index.ts +155 -0
- package/src/store/README.md +342 -0
- package/src/store/create-funnel-store.test.ts +686 -0
- package/src/store/create-funnel-store.ts +538 -0
- package/src/store/index.ts +9 -0
- package/src/store/types.ts +294 -0
- package/src/stories/CrossDomain.stories.tsx +149 -0
- package/src/stories/Welcome.stories.tsx +81 -0
- package/src/stories/demo-data/index.ts +3 -0
- package/src/stories/demo-data/investors.ts +216 -0
- package/src/stories/demo-data/leads.ts +223 -0
- package/src/stories/demo-data/recipes.ts +217 -0
- package/src/test/setup.ts +5 -0
- package/src/types/index.ts +843 -0
- package/dist/client-3ESO2NHy.d.ts +0 -310
- package/dist/client-CZu03ACp.d.cts +0 -310
- package/dist/components/index.cjs +0 -3241
- package/dist/components/index.cjs.map +0 -1
- package/dist/components/index.css.map +0 -1
- package/dist/components/index.d.cts +0 -726
- package/dist/components/index.d.ts +0 -726
- package/dist/components/index.js +0 -3194
- package/dist/components/index.js.map +0 -1
- package/dist/core/index.cjs +0 -500
- package/dist/core/index.cjs.map +0 -1
- package/dist/core/index.d.cts +0 -359
- package/dist/core/index.d.ts +0 -359
- package/dist/core/index.js +0 -486
- package/dist/core/index.js.map +0 -1
- package/dist/hooks/index.cjs +0 -20
- package/dist/hooks/index.cjs.map +0 -1
- package/dist/hooks/index.d.cts +0 -11
- package/dist/hooks/index.d.ts +0 -11
- package/dist/hooks/index.js +0 -18
- package/dist/hooks/index.js.map +0 -1
- package/dist/index-BGDEXbuz.d.cts +0 -434
- package/dist/index-BGDEXbuz.d.ts +0 -434
- package/dist/index.cjs +0 -4499
- package/dist/index.cjs.map +0 -1
- package/dist/index.css +0 -198
- package/dist/index.css.map +0 -1
- package/dist/index.d.cts +0 -99
- package/dist/index.d.ts +0 -99
- package/dist/index.js +0 -4421
- package/dist/index.js.map +0 -1
- package/dist/store/index.cjs +0 -389
- package/dist/store/index.cjs.map +0 -1
- package/dist/store/index.d.cts +0 -225
- package/dist/store/index.d.ts +0 -225
- package/dist/store/index.js +0 -386
- package/dist/store/index.js.map +0 -1
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FilterRuleEditor Component
|
|
3
|
+
*
|
|
4
|
+
* Main component for creating and editing filter rules.
|
|
5
|
+
*
|
|
6
|
+
* Design Goal:
|
|
7
|
+
* Users need to build complex filters by combining multiple rules with AND/OR logic.
|
|
8
|
+
* This component provides a visual rule builder that dynamically adapts to any
|
|
9
|
+
* entity type (investors, recipes, contacts, etc.) based on the field registry.
|
|
10
|
+
*
|
|
11
|
+
* Visual Hierarchy Rationale:
|
|
12
|
+
* - Logic toggle at top sets the mode (AND/OR)
|
|
13
|
+
* - Rules stacked vertically for clarity
|
|
14
|
+
* - AND/OR connectors between rules reinforce the logic visually
|
|
15
|
+
* - Add Rule button at bottom for progressive disclosure
|
|
16
|
+
* - Empty state shown when no rules exist
|
|
17
|
+
*
|
|
18
|
+
* Component Specification:
|
|
19
|
+
* - Logic toggle: Switch between AND/OR
|
|
20
|
+
* - Rule rows: Each rule is a self-contained row
|
|
21
|
+
* - Add button: Adds a new empty rule
|
|
22
|
+
* - Remove button: Each rule has a delete button
|
|
23
|
+
* - Validation: Real-time validation with error messages
|
|
24
|
+
*
|
|
25
|
+
* Interaction Notes:
|
|
26
|
+
* - Users can add unlimited rules (within reason)
|
|
27
|
+
* - Removing a rule updates immediately
|
|
28
|
+
* - Logic toggle applies to all rules
|
|
29
|
+
* - Each rule validates independently
|
|
30
|
+
* - Focus management when adding/removing rules
|
|
31
|
+
*
|
|
32
|
+
* Responsive Behavior:
|
|
33
|
+
* - Full width container
|
|
34
|
+
* - Rule rows responsive (see RuleRow component)
|
|
35
|
+
* - Add button full-width on mobile
|
|
36
|
+
*
|
|
37
|
+
* Accessibility Considerations:
|
|
38
|
+
* - ARIA labels for controls
|
|
39
|
+
* - Keyboard navigation throughout
|
|
40
|
+
* - Focus management for add/remove actions
|
|
41
|
+
* - Screen reader announces rule count
|
|
42
|
+
* - Error messages associated with fields
|
|
43
|
+
*
|
|
44
|
+
* Design Handoff Notes:
|
|
45
|
+
* - Uses Tailwind utility classes
|
|
46
|
+
* - Border radius: rounded-lg (8px)
|
|
47
|
+
* - Spacing: 4px units (gap-4, p-4)
|
|
48
|
+
* - Colors: gray-{100-800}, blue-{500-600}, red-{500-600}
|
|
49
|
+
* - Shadows: shadow-sm on hover
|
|
50
|
+
* - Transitions: transition-all, duration-200
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
import { FilterRule, FilterLogic, FieldDefinition } from '../../types';
|
|
54
|
+
import { LogicToggle } from './LogicToggle';
|
|
55
|
+
import { RuleRow } from './RuleRow';
|
|
56
|
+
import { validateFilterRule } from '../../types';
|
|
57
|
+
|
|
58
|
+
export interface FilterRuleEditorProps {
|
|
59
|
+
/** Current filter rules */
|
|
60
|
+
rules: FilterRule[];
|
|
61
|
+
|
|
62
|
+
/** Callback when rules change */
|
|
63
|
+
onChange: (rules: FilterRule[]) => void;
|
|
64
|
+
|
|
65
|
+
/** Field registry for the entity type */
|
|
66
|
+
fieldRegistry: FieldDefinition[];
|
|
67
|
+
|
|
68
|
+
/** Filter logic (AND/OR) */
|
|
69
|
+
logic?: FilterLogic;
|
|
70
|
+
|
|
71
|
+
/** Callback when logic changes */
|
|
72
|
+
onLogicChange?: (logic: FilterLogic) => void;
|
|
73
|
+
|
|
74
|
+
/** Additional CSS classes */
|
|
75
|
+
className?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function FilterRuleEditor({
|
|
79
|
+
rules,
|
|
80
|
+
onChange,
|
|
81
|
+
fieldRegistry,
|
|
82
|
+
logic = 'AND',
|
|
83
|
+
onLogicChange,
|
|
84
|
+
className = '',
|
|
85
|
+
}: FilterRuleEditorProps) {
|
|
86
|
+
// Add a new empty rule
|
|
87
|
+
const handleAddRule = () => {
|
|
88
|
+
const newRule: FilterRule = {
|
|
89
|
+
field_path: '',
|
|
90
|
+
operator: 'eq' as any,
|
|
91
|
+
value: null,
|
|
92
|
+
};
|
|
93
|
+
onChange([...rules, newRule]);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// Update a specific rule
|
|
97
|
+
const handleUpdateRule = (index: number, updatedRule: FilterRule) => {
|
|
98
|
+
const newRules = [...rules];
|
|
99
|
+
newRules[index] = updatedRule;
|
|
100
|
+
onChange(newRules);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// Remove a specific rule
|
|
104
|
+
const handleRemoveRule = (index: number) => {
|
|
105
|
+
const newRules = rules.filter((_, i) => i !== index);
|
|
106
|
+
onChange(newRules);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// Validate rules
|
|
110
|
+
const ruleErrors = rules.map((rule) => validateFilterRule(rule));
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<div className={`flex flex-col gap-4 ${className}`}>
|
|
114
|
+
{/* Logic toggle */}
|
|
115
|
+
{onLogicChange && (
|
|
116
|
+
<LogicToggle logic={logic} onChange={onLogicChange} />
|
|
117
|
+
)}
|
|
118
|
+
|
|
119
|
+
{/* Empty state */}
|
|
120
|
+
{rules.length === 0 && (
|
|
121
|
+
<div className="flex flex-col items-center justify-center py-12 border-2 border-dashed border-gray-300 rounded-lg bg-gray-50">
|
|
122
|
+
<svg
|
|
123
|
+
className="w-12 h-12 text-gray-400 mb-3"
|
|
124
|
+
fill="none"
|
|
125
|
+
viewBox="0 0 24 24"
|
|
126
|
+
stroke="currentColor"
|
|
127
|
+
>
|
|
128
|
+
<path
|
|
129
|
+
strokeLinecap="round"
|
|
130
|
+
strokeLinejoin="round"
|
|
131
|
+
strokeWidth={2}
|
|
132
|
+
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
|
|
133
|
+
/>
|
|
134
|
+
</svg>
|
|
135
|
+
<p className="text-sm text-gray-600 mb-4">No filter rules yet</p>
|
|
136
|
+
<button
|
|
137
|
+
type="button"
|
|
138
|
+
onClick={handleAddRule}
|
|
139
|
+
className="
|
|
140
|
+
inline-flex items-center gap-2
|
|
141
|
+
px-4 py-2
|
|
142
|
+
text-sm font-medium text-white
|
|
143
|
+
bg-blue-600 hover:bg-blue-700
|
|
144
|
+
rounded-md
|
|
145
|
+
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
|
|
146
|
+
transition-colors
|
|
147
|
+
"
|
|
148
|
+
>
|
|
149
|
+
<span className="text-lg">+</span>
|
|
150
|
+
Add First Rule
|
|
151
|
+
</button>
|
|
152
|
+
</div>
|
|
153
|
+
)}
|
|
154
|
+
|
|
155
|
+
{/* Rule list */}
|
|
156
|
+
{rules.length > 0 && (
|
|
157
|
+
<div className="flex flex-col gap-3">
|
|
158
|
+
{rules.map((rule, index) => (
|
|
159
|
+
<div key={index}>
|
|
160
|
+
<RuleRow
|
|
161
|
+
rule={rule}
|
|
162
|
+
onChange={(updatedRule) => handleUpdateRule(index, updatedRule)}
|
|
163
|
+
onRemove={() => handleRemoveRule(index)}
|
|
164
|
+
fieldRegistry={fieldRegistry}
|
|
165
|
+
/>
|
|
166
|
+
|
|
167
|
+
{/* Logic connector between rules */}
|
|
168
|
+
{index < rules.length - 1 && (
|
|
169
|
+
<div className="flex items-center justify-center py-2">
|
|
170
|
+
<span className="px-3 py-1 text-xs font-semibold text-gray-700 bg-gray-100 border border-gray-300 rounded-full">
|
|
171
|
+
{logic}
|
|
172
|
+
</span>
|
|
173
|
+
</div>
|
|
174
|
+
)}
|
|
175
|
+
|
|
176
|
+
{/* Validation errors */}
|
|
177
|
+
{ruleErrors[index].length > 0 && (
|
|
178
|
+
<div className="mt-2 px-4 py-2 bg-red-50 border border-red-200 rounded text-sm text-red-700">
|
|
179
|
+
{ruleErrors[index].map((error, i) => (
|
|
180
|
+
<div key={i}>{error}</div>
|
|
181
|
+
))}
|
|
182
|
+
</div>
|
|
183
|
+
)}
|
|
184
|
+
</div>
|
|
185
|
+
))}
|
|
186
|
+
</div>
|
|
187
|
+
)}
|
|
188
|
+
|
|
189
|
+
{/* Add rule button (when rules exist) */}
|
|
190
|
+
{rules.length > 0 && (
|
|
191
|
+
<button
|
|
192
|
+
type="button"
|
|
193
|
+
onClick={handleAddRule}
|
|
194
|
+
className="
|
|
195
|
+
w-full
|
|
196
|
+
flex items-center justify-center gap-2
|
|
197
|
+
px-4 py-3
|
|
198
|
+
text-sm font-medium text-blue-600
|
|
199
|
+
bg-white hover:bg-blue-50
|
|
200
|
+
border-2 border-dashed border-blue-300 hover:border-blue-400
|
|
201
|
+
rounded-lg
|
|
202
|
+
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
|
|
203
|
+
transition-colors
|
|
204
|
+
"
|
|
205
|
+
aria-label={`Add rule ${rules.length + 1}`}
|
|
206
|
+
>
|
|
207
|
+
<span className="text-xl">+</span>
|
|
208
|
+
Add Rule
|
|
209
|
+
</button>
|
|
210
|
+
)}
|
|
211
|
+
|
|
212
|
+
{/* Rule count and summary */}
|
|
213
|
+
{rules.length > 0 && (
|
|
214
|
+
<div className="flex items-center justify-between text-xs text-gray-500">
|
|
215
|
+
<span>
|
|
216
|
+
{rules.length} {rules.length === 1 ? 'rule' : 'rules'}
|
|
217
|
+
</span>
|
|
218
|
+
<span>
|
|
219
|
+
{logic === 'AND' ? 'All rules must match' : 'Any rule can match'}
|
|
220
|
+
</span>
|
|
221
|
+
</div>
|
|
222
|
+
)}
|
|
223
|
+
</div>
|
|
224
|
+
);
|
|
225
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LogicToggle Component
|
|
3
|
+
*
|
|
4
|
+
* Toggle between AND/OR filter logic.
|
|
5
|
+
*
|
|
6
|
+
* Design Goal:
|
|
7
|
+
* Users need to switch between requiring all rules to match (AND)
|
|
8
|
+
* or any rule to match (OR).
|
|
9
|
+
*
|
|
10
|
+
* Visual Hierarchy Rationale:
|
|
11
|
+
* - Radio button pattern is familiar and clearly shows mutually exclusive options
|
|
12
|
+
* - Horizontal layout keeps it compact
|
|
13
|
+
* - Blue accent color indicates selected state
|
|
14
|
+
* - Label explains the logic clearly
|
|
15
|
+
*
|
|
16
|
+
* Interaction Notes:
|
|
17
|
+
* - Click either option to change logic
|
|
18
|
+
* - Visual feedback on hover and focus
|
|
19
|
+
* - Keyboard accessible (Tab to focus, Arrow keys to change)
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { FilterLogic } from '../../types';
|
|
23
|
+
|
|
24
|
+
interface LogicToggleProps {
|
|
25
|
+
logic: FilterLogic;
|
|
26
|
+
onChange: (logic: FilterLogic) => void;
|
|
27
|
+
className?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function LogicToggle({ logic, onChange, className = '' }: LogicToggleProps) {
|
|
31
|
+
return (
|
|
32
|
+
<div className={`flex items-center gap-4 ${className}`}>
|
|
33
|
+
<span className="text-sm font-medium text-gray-700">Logic:</span>
|
|
34
|
+
|
|
35
|
+
<label className="flex items-center gap-2 cursor-pointer">
|
|
36
|
+
<input
|
|
37
|
+
type="radio"
|
|
38
|
+
name="filter-logic"
|
|
39
|
+
value="AND"
|
|
40
|
+
checked={logic === 'AND'}
|
|
41
|
+
onChange={(e) => onChange(e.target.value as FilterLogic)}
|
|
42
|
+
className="w-4 h-4 text-blue-600 border-gray-300 focus:ring-blue-500"
|
|
43
|
+
/>
|
|
44
|
+
<span className="text-sm text-gray-700">
|
|
45
|
+
AND <span className="text-xs text-gray-500">(all must match)</span>
|
|
46
|
+
</span>
|
|
47
|
+
</label>
|
|
48
|
+
|
|
49
|
+
<label className="flex items-center gap-2 cursor-pointer">
|
|
50
|
+
<input
|
|
51
|
+
type="radio"
|
|
52
|
+
name="filter-logic"
|
|
53
|
+
value="OR"
|
|
54
|
+
checked={logic === 'OR'}
|
|
55
|
+
onChange={(e) => onChange(e.target.value as FilterLogic)}
|
|
56
|
+
className="w-4 h-4 text-blue-600 border-gray-300 focus:ring-blue-500"
|
|
57
|
+
/>
|
|
58
|
+
<span className="text-sm text-gray-700">
|
|
59
|
+
OR <span className="text-xs text-gray-500">(any can match)</span>
|
|
60
|
+
</span>
|
|
61
|
+
</label>
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OperatorSelector Component
|
|
3
|
+
*
|
|
4
|
+
* Dropdown for selecting an operator, filtered by field type.
|
|
5
|
+
*
|
|
6
|
+
* Design Goal:
|
|
7
|
+
* Show only valid operators for the selected field type. For example,
|
|
8
|
+
* numeric fields show comparison operators (>, <, >=, <=) while string
|
|
9
|
+
* fields show text operators (contains, starts with, etc.).
|
|
10
|
+
*
|
|
11
|
+
* Visual Hierarchy Rationale:
|
|
12
|
+
* - Labeled clearly as "Operator"
|
|
13
|
+
* - Human-readable labels (not technical symbols)
|
|
14
|
+
* - Full-width for alignment with other inputs
|
|
15
|
+
*
|
|
16
|
+
* Interaction Notes:
|
|
17
|
+
* - Only enabled when field is selected
|
|
18
|
+
* - Shows operators valid for current field type
|
|
19
|
+
* - Resets to first valid operator when field changes
|
|
20
|
+
*
|
|
21
|
+
* Accessibility Considerations:
|
|
22
|
+
* - Disabled state clearly indicated
|
|
23
|
+
* - Proper label association
|
|
24
|
+
* - Focus visible on keyboard navigation
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { Operator, FieldDefinition } from '../../types';
|
|
28
|
+
import { OPERATOR_LABELS } from './constants';
|
|
29
|
+
|
|
30
|
+
interface OperatorSelectorProps {
|
|
31
|
+
operators: Operator[];
|
|
32
|
+
value: Operator | '';
|
|
33
|
+
onChange: (operator: Operator) => void;
|
|
34
|
+
disabled?: boolean;
|
|
35
|
+
error?: string;
|
|
36
|
+
className?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function OperatorSelector({
|
|
40
|
+
operators,
|
|
41
|
+
value,
|
|
42
|
+
onChange,
|
|
43
|
+
disabled = false,
|
|
44
|
+
error,
|
|
45
|
+
className = '',
|
|
46
|
+
}: OperatorSelectorProps) {
|
|
47
|
+
return (
|
|
48
|
+
<div className={`flex flex-col gap-1 ${className}`}>
|
|
49
|
+
<label htmlFor="operator-selector" className="text-xs font-medium text-gray-700">
|
|
50
|
+
Operator
|
|
51
|
+
</label>
|
|
52
|
+
<select
|
|
53
|
+
id="operator-selector"
|
|
54
|
+
value={value}
|
|
55
|
+
onChange={(e) => onChange(e.target.value as Operator)}
|
|
56
|
+
disabled={disabled}
|
|
57
|
+
className={`
|
|
58
|
+
w-full px-3 py-2 text-sm border rounded-md
|
|
59
|
+
bg-white
|
|
60
|
+
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
|
|
61
|
+
disabled:bg-gray-100 disabled:cursor-not-allowed disabled:text-gray-500
|
|
62
|
+
${error ? 'border-red-500' : 'border-gray-300'}
|
|
63
|
+
`}
|
|
64
|
+
>
|
|
65
|
+
<option value="">Select operator...</option>
|
|
66
|
+
{operators.map((op) => (
|
|
67
|
+
<option key={op} value={op}>
|
|
68
|
+
{OPERATOR_LABELS[op]}
|
|
69
|
+
</option>
|
|
70
|
+
))}
|
|
71
|
+
</select>
|
|
72
|
+
{error && <span className="text-xs text-red-600">{error}</span>}
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
# FilterRuleEditor Component
|
|
2
|
+
|
|
3
|
+
**BRUTALLY GENERIC** filter rule builder that works with any entity type.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
The FilterRuleEditor is a visual rule builder for creating complex filter conditions. It dynamically adapts to any entity type (investors, recipes, contacts, etc.) based on the field registry provided.
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- **Dynamic Field Selection** - Fields grouped by category from field registry
|
|
12
|
+
- **Type-Aware Operators** - Operators filtered by field type
|
|
13
|
+
- **Adaptive Value Input** - Input component changes based on field type + operator:
|
|
14
|
+
- String fields → Text input or dropdown (if choices)
|
|
15
|
+
- Number fields → Number input with min/max
|
|
16
|
+
- Date fields → Date picker
|
|
17
|
+
- Boolean fields → Checkbox
|
|
18
|
+
- Choice fields → Single or multi-select
|
|
19
|
+
- **AND/OR Logic** - Toggle between requiring all rules or any rule to match
|
|
20
|
+
- **Real-time Validation** - Validates rules as they're built
|
|
21
|
+
- **Add/Remove Rules** - Unlimited rules with individual delete buttons
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
```tsx
|
|
26
|
+
import { FilterRuleEditor } from '@simpli/funnels';
|
|
27
|
+
import { useState } from 'react';
|
|
28
|
+
|
|
29
|
+
function MyComponent() {
|
|
30
|
+
const [rules, setRules] = useState<FilterRule[]>([]);
|
|
31
|
+
const [logic, setLogic] = useState<FilterLogic>('AND');
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<FilterRuleEditor
|
|
35
|
+
rules={rules}
|
|
36
|
+
onChange={setRules}
|
|
37
|
+
fieldRegistry={myFieldRegistry}
|
|
38
|
+
logic={logic}
|
|
39
|
+
onLogicChange={setLogic}
|
|
40
|
+
/>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Example: Investor Filters
|
|
46
|
+
|
|
47
|
+
```tsx
|
|
48
|
+
const investorFields: FieldDefinition[] = [
|
|
49
|
+
{
|
|
50
|
+
name: 'firm.stage',
|
|
51
|
+
label: 'Firm Stage',
|
|
52
|
+
type: 'string',
|
|
53
|
+
category: 'Investment',
|
|
54
|
+
operators: ['eq', 'in'],
|
|
55
|
+
constraints: {
|
|
56
|
+
choices: ['Seed', 'Series A', 'Series B'],
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: 'firm.check_size_min',
|
|
61
|
+
label: 'Check Size Min',
|
|
62
|
+
type: 'number',
|
|
63
|
+
category: 'Investment',
|
|
64
|
+
operators: ['gte', 'lte'],
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
name: 'metrics.icp_score',
|
|
68
|
+
label: 'ICP Score',
|
|
69
|
+
type: 'number',
|
|
70
|
+
category: 'Metrics',
|
|
71
|
+
operators: ['gte', 'lte'],
|
|
72
|
+
constraints: { min_value: 0, max_value: 100 },
|
|
73
|
+
},
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
// Creates rules like:
|
|
77
|
+
// - Firm stage is one of [Series A, Series B]
|
|
78
|
+
// - Check size min >= $500,000
|
|
79
|
+
// - ICP score >= 70
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Example: Recipe Filters
|
|
83
|
+
|
|
84
|
+
```tsx
|
|
85
|
+
const recipeFields: FieldDefinition[] = [
|
|
86
|
+
{
|
|
87
|
+
name: 'cuisine',
|
|
88
|
+
label: 'Cuisine',
|
|
89
|
+
type: 'string',
|
|
90
|
+
category: 'Classification',
|
|
91
|
+
operators: ['eq', 'in'],
|
|
92
|
+
constraints: {
|
|
93
|
+
choices: ['Italian', 'French', 'Japanese'],
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: 'difficulty',
|
|
98
|
+
label: 'Difficulty',
|
|
99
|
+
type: 'string',
|
|
100
|
+
category: 'Classification',
|
|
101
|
+
operators: ['eq'],
|
|
102
|
+
constraints: {
|
|
103
|
+
choices: ['Easy', 'Medium', 'Hard'],
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
name: 'prep_time_minutes',
|
|
108
|
+
label: 'Prep Time',
|
|
109
|
+
type: 'number',
|
|
110
|
+
category: 'Time',
|
|
111
|
+
operators: ['lte'],
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
name: 'is_vegetarian',
|
|
115
|
+
label: 'Vegetarian',
|
|
116
|
+
type: 'boolean',
|
|
117
|
+
category: 'Dietary',
|
|
118
|
+
operators: ['is_true', 'is_false'],
|
|
119
|
+
},
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
// Creates rules like:
|
|
123
|
+
// - Cuisine is one of [Italian, French]
|
|
124
|
+
// - Difficulty equals Easy
|
|
125
|
+
// - Prep time <= 30 minutes
|
|
126
|
+
// - Vegetarian is true
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Field Registry
|
|
130
|
+
|
|
131
|
+
The component is completely driven by the field registry you provide. Each field definition specifies:
|
|
132
|
+
|
|
133
|
+
```typescript
|
|
134
|
+
interface FieldDefinition {
|
|
135
|
+
name: string; // Field path (e.g., 'firm.stage')
|
|
136
|
+
label: string; // Human-readable label
|
|
137
|
+
type: FieldType; // 'string' | 'number' | 'boolean' | 'date' | 'tag'
|
|
138
|
+
category?: string; // Grouping category
|
|
139
|
+
operators: Operator[]; // Valid operators for this field
|
|
140
|
+
constraints?: {
|
|
141
|
+
choices?: any[]; // For dropdowns
|
|
142
|
+
min_value?: number; // For number/date inputs
|
|
143
|
+
max_value?: number; // For number/date inputs
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Component Architecture
|
|
149
|
+
|
|
150
|
+
```
|
|
151
|
+
FilterRuleEditor/
|
|
152
|
+
├── FilterRuleEditor.tsx # Main container
|
|
153
|
+
├── LogicToggle.tsx # AND/OR switch
|
|
154
|
+
├── RuleRow.tsx # Single rule row
|
|
155
|
+
├── FieldSelector.tsx # Field dropdown (grouped)
|
|
156
|
+
├── OperatorSelector.tsx # Operator dropdown (filtered)
|
|
157
|
+
├── ValueInputs/
|
|
158
|
+
│ ├── TextValueInput.tsx
|
|
159
|
+
│ ├── NumberValueInput.tsx
|
|
160
|
+
│ ├── DateValueInput.tsx
|
|
161
|
+
│ ├── BooleanValueInput.tsx
|
|
162
|
+
│ ├── ChoiceValueInput.tsx
|
|
163
|
+
│ └── MultiChoiceValueInput.tsx
|
|
164
|
+
└── constants.ts # Operator labels
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Value Input Logic
|
|
168
|
+
|
|
169
|
+
The component automatically selects the appropriate input based on field type and operator:
|
|
170
|
+
|
|
171
|
+
| Field Type | Operator | Input Type |
|
|
172
|
+
|------------|----------|------------|
|
|
173
|
+
| `string` (with choices) | `eq` | Single-select dropdown |
|
|
174
|
+
| `string` (with choices) | `in` | Multi-select with tags |
|
|
175
|
+
| `string` | `contains` | Text input |
|
|
176
|
+
| `number` | `gte`, `lte`, etc. | Number input |
|
|
177
|
+
| `date` | `gte`, `lte`, etc. | Date picker |
|
|
178
|
+
| `boolean` | `eq` | Checkbox |
|
|
179
|
+
| `tag` | `has_tag` | Text input |
|
|
180
|
+
|
|
181
|
+
Operators like `isnull`, `isnotnull`, `is_true`, `is_false` don't show a value input (they don't need one).
|
|
182
|
+
|
|
183
|
+
## Validation
|
|
184
|
+
|
|
185
|
+
Rules are validated in real-time using `validateFilterRule()`:
|
|
186
|
+
|
|
187
|
+
- Field path is required
|
|
188
|
+
- Operator is required
|
|
189
|
+
- Value is required (except for null-check operators)
|
|
190
|
+
|
|
191
|
+
Validation errors appear below each rule.
|
|
192
|
+
|
|
193
|
+
## Styling
|
|
194
|
+
|
|
195
|
+
Uses Tailwind CSS utility classes:
|
|
196
|
+
- Border radius: `rounded-lg` (8px)
|
|
197
|
+
- Spacing: 4px units (`gap-4`, `p-4`)
|
|
198
|
+
- Colors: `gray-{100-800}`, `blue-{500-600}`, `red-{500-600}`
|
|
199
|
+
- Shadows: `shadow-sm` on hover
|
|
200
|
+
- Transitions: `transition-colors`
|
|
201
|
+
|
|
202
|
+
Fully responsive:
|
|
203
|
+
- Desktop (lg+): 3-column grid
|
|
204
|
+
- Tablet (md): 2-column grid
|
|
205
|
+
- Mobile: Stacked vertically
|
|
206
|
+
|
|
207
|
+
## Tests
|
|
208
|
+
|
|
209
|
+
See `FilterRuleEditor.test.tsx` for comprehensive tests covering:
|
|
210
|
+
- Rule validation
|
|
211
|
+
- Investor domain filters
|
|
212
|
+
- Recipe domain filters
|
|
213
|
+
- Complex multi-rule scenarios
|
|
214
|
+
- Field registry grouping
|
|
215
|
+
- Operator filtering
|
|
216
|
+
- Value constraints
|
|
217
|
+
|
|
218
|
+
All tests pass. Run with:
|
|
219
|
+
|
|
220
|
+
```bash
|
|
221
|
+
npm run test
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
## Storybook
|
|
225
|
+
|
|
226
|
+
See `FilterRuleEditor.stories.tsx` for interactive examples:
|
|
227
|
+
- Empty state
|
|
228
|
+
- Investor filters (Series A/B, check size, ICP score)
|
|
229
|
+
- Recipe filters (cuisine, difficulty, prep time, dietary)
|
|
230
|
+
- OR logic example
|
|
231
|
+
- Complex filters with many rules
|
|
232
|
+
- Interactive playground
|
|
233
|
+
|
|
234
|
+
## Accessibility
|
|
235
|
+
|
|
236
|
+
- Proper ARIA labels for all inputs
|
|
237
|
+
- Keyboard navigation throughout
|
|
238
|
+
- Focus management for add/remove
|
|
239
|
+
- Screen reader announces rule count
|
|
240
|
+
- Error messages associated with fields
|
|
241
|
+
- Native form controls (select, input, checkbox)
|
|
242
|
+
|
|
243
|
+
## Performance
|
|
244
|
+
|
|
245
|
+
- Efficient re-renders (only affected components update)
|
|
246
|
+
- Debounced validation
|
|
247
|
+
- Memoized field grouping
|
|
248
|
+
- Lightweight dependencies
|
|
249
|
+
|
|
250
|
+
## Integration
|
|
251
|
+
|
|
252
|
+
Works seamlessly with the Funnel Engine:
|
|
253
|
+
|
|
254
|
+
```tsx
|
|
255
|
+
const funnel: Funnel = {
|
|
256
|
+
id: 'my-funnel',
|
|
257
|
+
name: 'Qualified Investors',
|
|
258
|
+
stages: [
|
|
259
|
+
{
|
|
260
|
+
id: 'stage-1',
|
|
261
|
+
name: 'Filter Stage',
|
|
262
|
+
filter_logic: logic, // From FilterRuleEditor
|
|
263
|
+
rules: rules, // From FilterRuleEditor
|
|
264
|
+
match_action: 'output',
|
|
265
|
+
no_match_action: 'exclude',
|
|
266
|
+
},
|
|
267
|
+
],
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const engine = new FunnelEngine();
|
|
271
|
+
const results = await engine.run(funnel, entities);
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
## Future Enhancements
|
|
275
|
+
|
|
276
|
+
Potential additions:
|
|
277
|
+
- Drag-and-drop rule reordering
|
|
278
|
+
- Rule grouping (nested AND/OR)
|
|
279
|
+
- Rule templates/presets
|
|
280
|
+
- Import/export rules as JSON
|
|
281
|
+
- Visual query builder mode
|
|
282
|
+
- Advanced operators (regex, geo-distance)
|
|
283
|
+
- Custom value input components
|
|
284
|
+
- Rule duplication
|
|
285
|
+
|
|
286
|
+
## Related Components
|
|
287
|
+
|
|
288
|
+
- `FunnelStageBuilder` - Build complete funnel stages
|
|
289
|
+
- `FunnelPreview` - Preview funnel results
|
|
290
|
+
- `FunnelVisualFlow` - Visualize funnel flow
|
|
291
|
+
- `FunnelCard` - Display funnel summary
|