@startsimpli/funnels 0.1.4 → 0.1.5
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,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FunnelStageBuilder - Main component for creating/editing funnel stages
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* - Drag-and-drop reordering
|
|
6
|
+
* - Add/remove stages
|
|
7
|
+
* - Stage configuration (name, description, filters, actions)
|
|
8
|
+
* - Visual flow with arrows
|
|
9
|
+
* - Expandable stage cards
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import React, { useState, useCallback } from 'react';
|
|
13
|
+
import {
|
|
14
|
+
DndContext,
|
|
15
|
+
closestCenter,
|
|
16
|
+
KeyboardSensor,
|
|
17
|
+
PointerSensor,
|
|
18
|
+
useSensor,
|
|
19
|
+
useSensors,
|
|
20
|
+
DragEndEvent,
|
|
21
|
+
} from '@dnd-kit/core';
|
|
22
|
+
import {
|
|
23
|
+
arrayMove,
|
|
24
|
+
SortableContext,
|
|
25
|
+
sortableKeyboardCoordinates,
|
|
26
|
+
verticalListSortingStrategy,
|
|
27
|
+
} from '@dnd-kit/sortable';
|
|
28
|
+
import { Funnel, FunnelStage, FieldDefinition } from '../../types';
|
|
29
|
+
import { StageCard } from './StageCard';
|
|
30
|
+
import { AddStageButton } from './AddStageButton';
|
|
31
|
+
|
|
32
|
+
export interface FunnelStageBuilderProps {
|
|
33
|
+
/** The funnel being edited */
|
|
34
|
+
funnel: Funnel;
|
|
35
|
+
|
|
36
|
+
/** Callback when funnel is updated */
|
|
37
|
+
onUpdate: (funnel: Funnel) => void;
|
|
38
|
+
|
|
39
|
+
/** Available fields for filter rules */
|
|
40
|
+
fieldRegistry: FieldDefinition[];
|
|
41
|
+
|
|
42
|
+
/** Optional CSS class */
|
|
43
|
+
className?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Generate unique stage ID
|
|
48
|
+
*/
|
|
49
|
+
function generateStageId(): string {
|
|
50
|
+
return `stage-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Create a new empty stage
|
|
55
|
+
*/
|
|
56
|
+
function createEmptyStage(order: number): FunnelStage {
|
|
57
|
+
return {
|
|
58
|
+
id: generateStageId(),
|
|
59
|
+
order,
|
|
60
|
+
name: `Stage ${order + 1}`,
|
|
61
|
+
description: '',
|
|
62
|
+
filter_logic: 'AND',
|
|
63
|
+
rules: [],
|
|
64
|
+
match_action: 'continue',
|
|
65
|
+
no_match_action: 'continue',
|
|
66
|
+
match_tags: [],
|
|
67
|
+
no_match_tags: [],
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Validate stage name uniqueness
|
|
73
|
+
*/
|
|
74
|
+
function validateStageName(name: string, stages: FunnelStage[], currentStageId?: string): string | null {
|
|
75
|
+
const trimmedName = name.trim();
|
|
76
|
+
|
|
77
|
+
if (!trimmedName) {
|
|
78
|
+
return 'Stage name is required';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const duplicate = stages.find(
|
|
82
|
+
s => s.id !== currentStageId && s.name.trim().toLowerCase() === trimmedName.toLowerCase()
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
if (duplicate) {
|
|
86
|
+
return 'Stage name must be unique';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Main FunnelStageBuilder component
|
|
94
|
+
*/
|
|
95
|
+
export function FunnelStageBuilder({
|
|
96
|
+
funnel,
|
|
97
|
+
onUpdate,
|
|
98
|
+
fieldRegistry,
|
|
99
|
+
className = '',
|
|
100
|
+
}: FunnelStageBuilderProps) {
|
|
101
|
+
// Track expanded stages
|
|
102
|
+
const [expandedStages, setExpandedStages] = useState<Set<string>>(
|
|
103
|
+
new Set(funnel.stages.map(s => s.id))
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
// Validation errors
|
|
107
|
+
const [errors, setErrors] = useState<Map<string, string>>(new Map());
|
|
108
|
+
|
|
109
|
+
// Configure drag-and-drop sensors
|
|
110
|
+
const sensors = useSensors(
|
|
111
|
+
useSensor(PointerSensor),
|
|
112
|
+
useSensor(KeyboardSensor, {
|
|
113
|
+
coordinateGetter: sortableKeyboardCoordinates,
|
|
114
|
+
})
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Toggle stage expanded/collapsed
|
|
119
|
+
*/
|
|
120
|
+
const toggleExpanded = useCallback((stageId: string) => {
|
|
121
|
+
setExpandedStages(prev => {
|
|
122
|
+
const next = new Set(prev);
|
|
123
|
+
if (next.has(stageId)) {
|
|
124
|
+
next.delete(stageId);
|
|
125
|
+
} else {
|
|
126
|
+
next.add(stageId);
|
|
127
|
+
}
|
|
128
|
+
return next;
|
|
129
|
+
});
|
|
130
|
+
}, []);
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Add new stage
|
|
134
|
+
*/
|
|
135
|
+
const handleAddStage = useCallback((insertAfterIndex?: number) => {
|
|
136
|
+
const newOrder = insertAfterIndex !== undefined ? insertAfterIndex + 1 : funnel.stages.length;
|
|
137
|
+
const newStage = createEmptyStage(newOrder);
|
|
138
|
+
|
|
139
|
+
// Update orders for stages after insertion point
|
|
140
|
+
const updatedStages = funnel.stages.map(stage => {
|
|
141
|
+
if (stage.order >= newOrder) {
|
|
142
|
+
return { ...stage, order: stage.order + 1 };
|
|
143
|
+
}
|
|
144
|
+
return stage;
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// Insert new stage
|
|
148
|
+
updatedStages.splice(newOrder, 0, newStage);
|
|
149
|
+
|
|
150
|
+
// Expand new stage
|
|
151
|
+
setExpandedStages(prev => new Set(prev).add(newStage.id));
|
|
152
|
+
|
|
153
|
+
onUpdate({
|
|
154
|
+
...funnel,
|
|
155
|
+
stages: updatedStages,
|
|
156
|
+
});
|
|
157
|
+
}, [funnel, onUpdate]);
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Remove stage
|
|
161
|
+
*/
|
|
162
|
+
const handleRemoveStage = useCallback((stageId: string) => {
|
|
163
|
+
const stageIndex = funnel.stages.findIndex(s => s.id === stageId);
|
|
164
|
+
if (stageIndex === -1) return;
|
|
165
|
+
|
|
166
|
+
// Remove stage
|
|
167
|
+
const updatedStages = funnel.stages.filter(s => s.id !== stageId);
|
|
168
|
+
|
|
169
|
+
// Reorder remaining stages
|
|
170
|
+
updatedStages.forEach((stage, index) => {
|
|
171
|
+
stage.order = index;
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Remove from expanded set
|
|
175
|
+
setExpandedStages(prev => {
|
|
176
|
+
const next = new Set(prev);
|
|
177
|
+
next.delete(stageId);
|
|
178
|
+
return next;
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// Remove validation errors
|
|
182
|
+
setErrors(prev => {
|
|
183
|
+
const next = new Map(prev);
|
|
184
|
+
next.delete(stageId);
|
|
185
|
+
return next;
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
onUpdate({
|
|
189
|
+
...funnel,
|
|
190
|
+
stages: updatedStages,
|
|
191
|
+
});
|
|
192
|
+
}, [funnel, onUpdate]);
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Update stage
|
|
196
|
+
*/
|
|
197
|
+
const handleUpdateStage = useCallback((updatedStage: FunnelStage) => {
|
|
198
|
+
// Validate stage name
|
|
199
|
+
const nameError = validateStageName(updatedStage.name, funnel.stages, updatedStage.id);
|
|
200
|
+
|
|
201
|
+
setErrors(prev => {
|
|
202
|
+
const next = new Map(prev);
|
|
203
|
+
if (nameError) {
|
|
204
|
+
next.set(updatedStage.id, nameError);
|
|
205
|
+
} else {
|
|
206
|
+
next.delete(updatedStage.id);
|
|
207
|
+
}
|
|
208
|
+
return next;
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const updatedStages = funnel.stages.map(stage =>
|
|
212
|
+
stage.id === updatedStage.id ? updatedStage : stage
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
onUpdate({
|
|
216
|
+
...funnel,
|
|
217
|
+
stages: updatedStages,
|
|
218
|
+
});
|
|
219
|
+
}, [funnel, onUpdate]);
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Handle drag end
|
|
223
|
+
*/
|
|
224
|
+
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
|
225
|
+
const { active, over } = event;
|
|
226
|
+
|
|
227
|
+
if (!over || active.id === over.id) {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const oldIndex = funnel.stages.findIndex(s => s.id === active.id);
|
|
232
|
+
const newIndex = funnel.stages.findIndex(s => s.id === over.id);
|
|
233
|
+
|
|
234
|
+
if (oldIndex === -1 || newIndex === -1) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Reorder stages
|
|
239
|
+
const reorderedStages = arrayMove(funnel.stages, oldIndex, newIndex);
|
|
240
|
+
|
|
241
|
+
// Update order values
|
|
242
|
+
reorderedStages.forEach((stage, index) => {
|
|
243
|
+
stage.order = index;
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
onUpdate({
|
|
247
|
+
...funnel,
|
|
248
|
+
stages: reorderedStages,
|
|
249
|
+
});
|
|
250
|
+
}, [funnel, onUpdate]);
|
|
251
|
+
|
|
252
|
+
return (
|
|
253
|
+
<div className={`funnel-stage-builder ${className}`}>
|
|
254
|
+
{/* Add stage at top */}
|
|
255
|
+
<div className="mb-4">
|
|
256
|
+
<AddStageButton onClick={() => handleAddStage()} position="top" />
|
|
257
|
+
</div>
|
|
258
|
+
|
|
259
|
+
{/* Stage list with drag-and-drop */}
|
|
260
|
+
<DndContext
|
|
261
|
+
sensors={sensors}
|
|
262
|
+
collisionDetection={closestCenter}
|
|
263
|
+
onDragEnd={handleDragEnd}
|
|
264
|
+
>
|
|
265
|
+
<SortableContext
|
|
266
|
+
items={funnel.stages.map(s => s.id)}
|
|
267
|
+
strategy={verticalListSortingStrategy}
|
|
268
|
+
>
|
|
269
|
+
{funnel.stages.map((stage, index) => (
|
|
270
|
+
<div key={stage.id} className="stage-wrapper">
|
|
271
|
+
<StageCard
|
|
272
|
+
stage={stage}
|
|
273
|
+
expanded={expandedStages.has(stage.id)}
|
|
274
|
+
onToggleExpanded={() => toggleExpanded(stage.id)}
|
|
275
|
+
onUpdate={handleUpdateStage}
|
|
276
|
+
onRemove={() => handleRemoveStage(stage.id)}
|
|
277
|
+
fieldRegistry={fieldRegistry}
|
|
278
|
+
error={errors.get(stage.id)}
|
|
279
|
+
showWarnings={stage.rules.length === 0}
|
|
280
|
+
/>
|
|
281
|
+
|
|
282
|
+
{/* Flow arrow between stages */}
|
|
283
|
+
{index < funnel.stages.length - 1 && (
|
|
284
|
+
<div className="stage-arrow">
|
|
285
|
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
|
286
|
+
<path
|
|
287
|
+
d="M12 5v14m0 0l-4-4m4 4l4-4"
|
|
288
|
+
stroke="currentColor"
|
|
289
|
+
strokeWidth="2"
|
|
290
|
+
strokeLinecap="round"
|
|
291
|
+
strokeLinejoin="round"
|
|
292
|
+
/>
|
|
293
|
+
</svg>
|
|
294
|
+
</div>
|
|
295
|
+
)}
|
|
296
|
+
</div>
|
|
297
|
+
))}
|
|
298
|
+
</SortableContext>
|
|
299
|
+
</DndContext>
|
|
300
|
+
|
|
301
|
+
{/* Add stage at bottom */}
|
|
302
|
+
{funnel.stages.length > 0 && (
|
|
303
|
+
<div className="mt-4">
|
|
304
|
+
<AddStageButton
|
|
305
|
+
onClick={() => handleAddStage(funnel.stages.length - 1)}
|
|
306
|
+
position="bottom"
|
|
307
|
+
/>
|
|
308
|
+
</div>
|
|
309
|
+
)}
|
|
310
|
+
|
|
311
|
+
{/* Empty state */}
|
|
312
|
+
{funnel.stages.length === 0 && (
|
|
313
|
+
<div className="empty-state">
|
|
314
|
+
<p className="text-gray-500 text-center py-8">
|
|
315
|
+
No stages yet. Add your first stage to get started.
|
|
316
|
+
</p>
|
|
317
|
+
</div>
|
|
318
|
+
)}
|
|
319
|
+
</div>
|
|
320
|
+
);
|
|
321
|
+
}
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
# FunnelStageBuilder
|
|
2
|
+
|
|
3
|
+
Visual builder component for creating and editing funnel stages with drag-and-drop reordering.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
The FunnelStageBuilder provides a complete interface for managing funnel stages:
|
|
8
|
+
|
|
9
|
+
- **Drag-and-Drop Reordering** - Reorder stages visually using @dnd-kit
|
|
10
|
+
- **Add/Remove Stages** - Add stages at top, bottom, or inline
|
|
11
|
+
- **Stage Configuration** - Name, description, filter logic, actions, and tags
|
|
12
|
+
- **Visual Flow** - Arrows showing flow between stages
|
|
13
|
+
- **Expandable Cards** - Collapse stages to see high-level summary
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities react react-dom
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Basic Usage
|
|
22
|
+
|
|
23
|
+
```tsx
|
|
24
|
+
import { FunnelStageBuilder } from '@simpli/funnels/components';
|
|
25
|
+
import { Funnel, FieldDefinition } from '@simpli/funnels';
|
|
26
|
+
|
|
27
|
+
const fieldRegistry: FieldDefinition[] = [
|
|
28
|
+
{
|
|
29
|
+
name: 'firm.stage',
|
|
30
|
+
label: 'Investment Stage',
|
|
31
|
+
type: 'string',
|
|
32
|
+
operators: ['eq', 'ne', 'in'],
|
|
33
|
+
category: 'Firm',
|
|
34
|
+
},
|
|
35
|
+
// ... more fields
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
function MyComponent() {
|
|
39
|
+
const [funnel, setFunnel] = useState<Funnel>({
|
|
40
|
+
id: 'funnel-1',
|
|
41
|
+
name: 'My Funnel',
|
|
42
|
+
status: 'draft',
|
|
43
|
+
input_type: 'contacts',
|
|
44
|
+
stages: [],
|
|
45
|
+
created_at: new Date().toISOString(),
|
|
46
|
+
updated_at: new Date().toISOString(),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<FunnelStageBuilder
|
|
51
|
+
funnel={funnel}
|
|
52
|
+
onUpdate={setFunnel}
|
|
53
|
+
fieldRegistry={fieldRegistry}
|
|
54
|
+
/>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Props
|
|
60
|
+
|
|
61
|
+
### FunnelStageBuilderProps
|
|
62
|
+
|
|
63
|
+
| Prop | Type | Required | Description |
|
|
64
|
+
|------|------|----------|-------------|
|
|
65
|
+
| `funnel` | `Funnel` | Yes | The funnel being edited |
|
|
66
|
+
| `onUpdate` | `(funnel: Funnel) => void` | Yes | Callback when funnel is updated |
|
|
67
|
+
| `fieldRegistry` | `FieldDefinition[]` | Yes | Available fields for filter rules |
|
|
68
|
+
| `className` | `string` | No | Optional CSS class |
|
|
69
|
+
|
|
70
|
+
## Features
|
|
71
|
+
|
|
72
|
+
### 1. Drag-and-Drop Reordering
|
|
73
|
+
|
|
74
|
+
Uses @dnd-kit for accessible, performant drag-and-drop:
|
|
75
|
+
|
|
76
|
+
```tsx
|
|
77
|
+
// Automatically handles:
|
|
78
|
+
// - Stage reordering
|
|
79
|
+
// - Order value updates
|
|
80
|
+
// - Visual feedback during drag
|
|
81
|
+
// - Keyboard navigation (Space/Enter to grab, Arrow keys to move)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### 2. Add/Remove Stages
|
|
85
|
+
|
|
86
|
+
```tsx
|
|
87
|
+
// Add stage at top
|
|
88
|
+
<AddStageButton onClick={() => handleAddStage()} position="top" />
|
|
89
|
+
|
|
90
|
+
// Add stage at bottom
|
|
91
|
+
<AddStageButton onClick={() => handleAddStage(lastIndex)} position="bottom" />
|
|
92
|
+
|
|
93
|
+
// Remove stage
|
|
94
|
+
<button onClick={() => handleRemoveStage(stageId)}>Delete</button>
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### 3. Stage Configuration
|
|
98
|
+
|
|
99
|
+
Each stage can be configured with:
|
|
100
|
+
|
|
101
|
+
- **Name** - Required, must be unique
|
|
102
|
+
- **Description** - Optional
|
|
103
|
+
- **Filter Logic** - AND (all rules must match) or OR (at least one must match)
|
|
104
|
+
- **Rules** - Array of filter rules (requires FilterRuleEditor component)
|
|
105
|
+
- **Match Action** - What to do when rules match:
|
|
106
|
+
- `continue` - Continue to next stage
|
|
107
|
+
- `tag` - Add tags and stop
|
|
108
|
+
- `tag_continue` - Add tags and continue
|
|
109
|
+
- `output` - Add to output and stop
|
|
110
|
+
- **No Match Action** - What to do when rules don't match:
|
|
111
|
+
- `continue` - Continue to next stage
|
|
112
|
+
- `exclude` - Exclude from output and stop
|
|
113
|
+
- `tag_exclude` - Add tags, exclude, and stop
|
|
114
|
+
- **Tags** - Tags to add on match/no-match
|
|
115
|
+
|
|
116
|
+
### 4. Validation
|
|
117
|
+
|
|
118
|
+
Built-in validation for:
|
|
119
|
+
|
|
120
|
+
- **Required name** - Stage must have a name
|
|
121
|
+
- **Unique names** - No duplicate stage names
|
|
122
|
+
- **Warning for empty rules** - Shows warning if stage has no filter rules
|
|
123
|
+
|
|
124
|
+
```tsx
|
|
125
|
+
// Validation errors shown inline
|
|
126
|
+
<div className="error-message">
|
|
127
|
+
Stage name must be unique
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
// Warnings shown inline
|
|
131
|
+
<div className="warning-message">
|
|
132
|
+
Stage has no filter rules
|
|
133
|
+
</div>
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### 5. Debounced Updates
|
|
137
|
+
|
|
138
|
+
Text inputs (name, description) use debounced updates to avoid excessive callbacks:
|
|
139
|
+
|
|
140
|
+
```tsx
|
|
141
|
+
// Name updates after 300ms
|
|
142
|
+
// Description updates after 500ms
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Components
|
|
146
|
+
|
|
147
|
+
### FunnelStageBuilder
|
|
148
|
+
|
|
149
|
+
Main container component that orchestrates all functionality.
|
|
150
|
+
|
|
151
|
+
### StageCard
|
|
152
|
+
|
|
153
|
+
Individual stage card with:
|
|
154
|
+
- Drag handle
|
|
155
|
+
- Expand/collapse toggle
|
|
156
|
+
- Delete button
|
|
157
|
+
- Validation errors/warnings
|
|
158
|
+
- Collapsed summary
|
|
159
|
+
- Expanded form
|
|
160
|
+
|
|
161
|
+
### StageForm
|
|
162
|
+
|
|
163
|
+
Stage configuration form with:
|
|
164
|
+
- Name input
|
|
165
|
+
- Description textarea
|
|
166
|
+
- Filter logic toggle
|
|
167
|
+
- Match/no-match actions
|
|
168
|
+
- Tags input
|
|
169
|
+
- Rules editor placeholder
|
|
170
|
+
|
|
171
|
+
### StageActions
|
|
172
|
+
|
|
173
|
+
Action dropdowns for match/no-match actions with descriptions.
|
|
174
|
+
|
|
175
|
+
### TagInput
|
|
176
|
+
|
|
177
|
+
Multi-tag input component:
|
|
178
|
+
- Add tags with Enter or comma
|
|
179
|
+
- Remove tags with click
|
|
180
|
+
- Visual tag chips
|
|
181
|
+
- Duplicate prevention
|
|
182
|
+
|
|
183
|
+
### AddStageButton
|
|
184
|
+
|
|
185
|
+
Button to add new stages with context-aware labels.
|
|
186
|
+
|
|
187
|
+
## Styling
|
|
188
|
+
|
|
189
|
+
The component comes with base styles in `FunnelStageBuilder.css`:
|
|
190
|
+
|
|
191
|
+
```tsx
|
|
192
|
+
import './FunnelStageBuilder.css';
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Key CSS classes:
|
|
196
|
+
|
|
197
|
+
- `.funnel-stage-builder` - Main container
|
|
198
|
+
- `.stage-card` - Stage card
|
|
199
|
+
- `.stage-header` - Card header
|
|
200
|
+
- `.stage-form` - Stage configuration form
|
|
201
|
+
- `.drag-handle` - Drag handle button
|
|
202
|
+
- `.tag-chip` - Tag pill
|
|
203
|
+
- `.error-message` - Validation error
|
|
204
|
+
- `.warning-message` - Warning message
|
|
205
|
+
|
|
206
|
+
Override styles by targeting these classes or passing custom `className`.
|
|
207
|
+
|
|
208
|
+
## Accessibility
|
|
209
|
+
|
|
210
|
+
The component follows accessibility best practices:
|
|
211
|
+
|
|
212
|
+
- **Keyboard Navigation** - Full keyboard support for drag-and-drop
|
|
213
|
+
- **ARIA Labels** - All interactive elements have appropriate labels
|
|
214
|
+
- **Focus Management** - Logical tab order
|
|
215
|
+
- **Screen Reader Support** - Semantic HTML and ARIA attributes
|
|
216
|
+
|
|
217
|
+
## Testing
|
|
218
|
+
|
|
219
|
+
The component includes comprehensive tests:
|
|
220
|
+
|
|
221
|
+
```bash
|
|
222
|
+
npm run test
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
Test coverage includes:
|
|
226
|
+
|
|
227
|
+
- Rendering with 0, 1, and multiple stages
|
|
228
|
+
- Adding stages at top/bottom
|
|
229
|
+
- Removing stages
|
|
230
|
+
- Reordering stages (drag-and-drop)
|
|
231
|
+
- Expanding/collapsing stages
|
|
232
|
+
- Form input changes
|
|
233
|
+
- Validation (unique names, required fields)
|
|
234
|
+
- Tag input (add/remove tags)
|
|
235
|
+
- Action dropdowns
|
|
236
|
+
|
|
237
|
+
## Integration Notes
|
|
238
|
+
|
|
239
|
+
### FilterRuleEditor Integration
|
|
240
|
+
|
|
241
|
+
The component includes a placeholder for the FilterRuleEditor:
|
|
242
|
+
|
|
243
|
+
```tsx
|
|
244
|
+
{/* TODO: Integrate FilterRuleEditor component once available */}
|
|
245
|
+
<div className="rules-placeholder">
|
|
246
|
+
<p>FilterRuleEditor component will be integrated here</p>
|
|
247
|
+
</div>
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
When integrating FilterRuleEditor:
|
|
251
|
+
|
|
252
|
+
```tsx
|
|
253
|
+
import { FilterRuleEditor } from '../FilterRuleEditor';
|
|
254
|
+
|
|
255
|
+
// In StageForm.tsx
|
|
256
|
+
<FilterRuleEditor
|
|
257
|
+
rules={stage.rules}
|
|
258
|
+
onUpdate={(rules) => onUpdate({ ...stage, rules })}
|
|
259
|
+
fieldRegistry={fieldRegistry}
|
|
260
|
+
/>
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
### State Management
|
|
264
|
+
|
|
265
|
+
The component is stateless - all state is managed by parent:
|
|
266
|
+
|
|
267
|
+
```tsx
|
|
268
|
+
// Parent manages funnel state
|
|
269
|
+
const [funnel, setFunnel] = useState<Funnel>(...);
|
|
270
|
+
|
|
271
|
+
// Component calls onUpdate when user makes changes
|
|
272
|
+
<FunnelStageBuilder
|
|
273
|
+
funnel={funnel}
|
|
274
|
+
onUpdate={setFunnel}
|
|
275
|
+
fieldRegistry={fieldRegistry}
|
|
276
|
+
/>
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
For global state management, wrap with your state library:
|
|
280
|
+
|
|
281
|
+
```tsx
|
|
282
|
+
// With Zustand
|
|
283
|
+
import { useFunnelStore } from './store';
|
|
284
|
+
|
|
285
|
+
function MyComponent() {
|
|
286
|
+
const { funnel, updateFunnel } = useFunnelStore();
|
|
287
|
+
|
|
288
|
+
return (
|
|
289
|
+
<FunnelStageBuilder
|
|
290
|
+
funnel={funnel}
|
|
291
|
+
onUpdate={updateFunnel}
|
|
292
|
+
fieldRegistry={fieldRegistry}
|
|
293
|
+
/>
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
## Examples
|
|
299
|
+
|
|
300
|
+
See `FunnelStageBuilder.stories.tsx` for interactive examples:
|
|
301
|
+
|
|
302
|
+
- Empty state
|
|
303
|
+
- Single stage
|
|
304
|
+
- Multiple stages
|
|
305
|
+
- With validation errors
|
|
306
|
+
- Different action combinations
|
|
307
|
+
|
|
308
|
+
Run Storybook to view:
|
|
309
|
+
|
|
310
|
+
```bash
|
|
311
|
+
npm run storybook
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
## Generic Design
|
|
315
|
+
|
|
316
|
+
This component works with **any** entity type and field registry:
|
|
317
|
+
|
|
318
|
+
```tsx
|
|
319
|
+
// Works for investors
|
|
320
|
+
const investorFields = [
|
|
321
|
+
{ name: 'firm.stage', label: 'Stage', type: 'string', operators: ['eq'] },
|
|
322
|
+
];
|
|
323
|
+
|
|
324
|
+
// Works for recipes
|
|
325
|
+
const recipeFields = [
|
|
326
|
+
{ name: 'recipe.cuisine', label: 'Cuisine', type: 'string', operators: ['eq'] },
|
|
327
|
+
];
|
|
328
|
+
|
|
329
|
+
// Works for leads
|
|
330
|
+
const leadFields = [
|
|
331
|
+
{ name: 'lead.score', label: 'Score', type: 'number', operators: ['gt'] },
|
|
332
|
+
];
|
|
333
|
+
|
|
334
|
+
// Same component, different field registries!
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
No domain-specific logic. Pure, generic UI.
|
|
338
|
+
|
|
339
|
+
## License
|
|
340
|
+
|
|
341
|
+
MIT
|