@startsimpli/funnels 0.1.3 → 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 -3243
- 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 -3196
- 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 -21
- 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 -19
- 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 -391
- 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 -388
- package/dist/store/index.js.map +0 -1
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FunnelStageBuilder Storybook stories
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
6
|
+
import { useState } from 'react';
|
|
7
|
+
import { FunnelStageBuilder } from './FunnelStageBuilder';
|
|
8
|
+
import { Funnel, FieldDefinition } from '../../types';
|
|
9
|
+
import './FunnelStageBuilder.css';
|
|
10
|
+
|
|
11
|
+
// Sample field registry
|
|
12
|
+
const sampleFieldRegistry: FieldDefinition[] = [
|
|
13
|
+
// Firm fields
|
|
14
|
+
{
|
|
15
|
+
name: 'firm.name',
|
|
16
|
+
label: 'Firm Name',
|
|
17
|
+
type: 'string',
|
|
18
|
+
operators: ['eq', 'ne', 'contains', 'startswith'],
|
|
19
|
+
category: 'Firm',
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
name: 'firm.stage',
|
|
23
|
+
label: 'Investment Stage',
|
|
24
|
+
type: 'string',
|
|
25
|
+
operators: ['eq', 'ne', 'in', 'not_in'],
|
|
26
|
+
category: 'Firm',
|
|
27
|
+
constraints: {
|
|
28
|
+
choices: ['Pre-Seed', 'Seed', 'Series A', 'Series B', 'Series C+', 'Growth'],
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
name: 'firm.aum_usd',
|
|
33
|
+
label: 'AUM (USD)',
|
|
34
|
+
type: 'number',
|
|
35
|
+
operators: ['gt', 'lt', 'gte', 'lte'],
|
|
36
|
+
category: 'Firm',
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: 'firm.check_size_min',
|
|
40
|
+
label: 'Min Check Size',
|
|
41
|
+
type: 'number',
|
|
42
|
+
operators: ['gt', 'lt', 'gte', 'lte'],
|
|
43
|
+
category: 'Firm',
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: 'firm.check_size_max',
|
|
47
|
+
label: 'Max Check Size',
|
|
48
|
+
type: 'number',
|
|
49
|
+
operators: ['gt', 'lt', 'gte', 'lte'],
|
|
50
|
+
category: 'Firm',
|
|
51
|
+
},
|
|
52
|
+
// Investor fields
|
|
53
|
+
{
|
|
54
|
+
name: 'investor.name',
|
|
55
|
+
label: 'Investor Name',
|
|
56
|
+
type: 'string',
|
|
57
|
+
operators: ['eq', 'ne', 'contains', 'startswith'],
|
|
58
|
+
category: 'Investor',
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: 'investor.title',
|
|
62
|
+
label: 'Title',
|
|
63
|
+
type: 'string',
|
|
64
|
+
operators: ['eq', 'ne', 'contains'],
|
|
65
|
+
category: 'Investor',
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: 'investor.email',
|
|
69
|
+
label: 'Email',
|
|
70
|
+
type: 'string',
|
|
71
|
+
operators: ['eq', 'ne', 'contains', 'isnotnull'],
|
|
72
|
+
category: 'Investor',
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
name: 'investor.linkedin_url',
|
|
76
|
+
label: 'LinkedIn URL',
|
|
77
|
+
type: 'string',
|
|
78
|
+
operators: ['isnotnull', 'isnull', 'contains'],
|
|
79
|
+
category: 'Investor',
|
|
80
|
+
},
|
|
81
|
+
// Tag field
|
|
82
|
+
{
|
|
83
|
+
name: 'tags',
|
|
84
|
+
label: 'Tags',
|
|
85
|
+
type: 'tag',
|
|
86
|
+
operators: ['has_tag', 'not_has_tag'],
|
|
87
|
+
category: 'Metadata',
|
|
88
|
+
},
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
// Create empty funnel
|
|
92
|
+
function createEmptyFunnel(): Funnel {
|
|
93
|
+
return {
|
|
94
|
+
id: 'funnel-1',
|
|
95
|
+
name: 'VC Outreach Funnel',
|
|
96
|
+
description: 'Filter and prioritize venture capital firms for outreach',
|
|
97
|
+
status: 'draft',
|
|
98
|
+
input_type: 'both',
|
|
99
|
+
stages: [],
|
|
100
|
+
created_at: new Date().toISOString(),
|
|
101
|
+
updated_at: new Date().toISOString(),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Create funnel with sample stages
|
|
106
|
+
function createSampleFunnel(): Funnel {
|
|
107
|
+
return {
|
|
108
|
+
id: 'funnel-1',
|
|
109
|
+
name: 'VC Outreach Funnel',
|
|
110
|
+
description: 'Filter and prioritize venture capital firms for outreach',
|
|
111
|
+
status: 'draft',
|
|
112
|
+
input_type: 'both',
|
|
113
|
+
stages: [
|
|
114
|
+
{
|
|
115
|
+
id: 'stage-1',
|
|
116
|
+
order: 0,
|
|
117
|
+
name: 'High ICP Score',
|
|
118
|
+
description: 'Firms with strong ideal customer profile match',
|
|
119
|
+
filter_logic: 'AND',
|
|
120
|
+
rules: [
|
|
121
|
+
{
|
|
122
|
+
field_path: 'firm.stage',
|
|
123
|
+
operator: 'in',
|
|
124
|
+
value: ['Seed', 'Series A'],
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
field_path: 'firm.check_size_min',
|
|
128
|
+
operator: 'lte',
|
|
129
|
+
value: 500000,
|
|
130
|
+
},
|
|
131
|
+
],
|
|
132
|
+
match_action: 'tag_continue',
|
|
133
|
+
no_match_action: 'continue',
|
|
134
|
+
match_tags: ['high-priority', 'icp-match'],
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
id: 'stage-2',
|
|
138
|
+
order: 1,
|
|
139
|
+
name: 'Has Contact Info',
|
|
140
|
+
description: 'Ensure we have investor email or LinkedIn',
|
|
141
|
+
filter_logic: 'OR',
|
|
142
|
+
rules: [
|
|
143
|
+
{
|
|
144
|
+
field_path: 'investor.email',
|
|
145
|
+
operator: 'isnotnull',
|
|
146
|
+
value: null,
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
field_path: 'investor.linkedin_url',
|
|
150
|
+
operator: 'isnotnull',
|
|
151
|
+
value: null,
|
|
152
|
+
},
|
|
153
|
+
],
|
|
154
|
+
match_action: 'output',
|
|
155
|
+
no_match_action: 'exclude',
|
|
156
|
+
no_match_tags: ['no-contact-info'],
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
created_at: new Date().toISOString(),
|
|
160
|
+
updated_at: new Date().toISOString(),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const meta: Meta<typeof FunnelStageBuilder> = {
|
|
165
|
+
title: 'Components/FunnelStageBuilder',
|
|
166
|
+
component: FunnelStageBuilder,
|
|
167
|
+
parameters: {
|
|
168
|
+
layout: 'padded',
|
|
169
|
+
},
|
|
170
|
+
tags: ['autodocs'],
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
export default meta;
|
|
174
|
+
type Story = StoryObj<typeof FunnelStageBuilder>;
|
|
175
|
+
|
|
176
|
+
// Wrapper component for stateful interaction
|
|
177
|
+
function InteractiveFunnelStageBuilder({ initialFunnel }: { initialFunnel: Funnel }) {
|
|
178
|
+
const [funnel, setFunnel] = useState(initialFunnel);
|
|
179
|
+
|
|
180
|
+
return (
|
|
181
|
+
<div>
|
|
182
|
+
<FunnelStageBuilder
|
|
183
|
+
funnel={funnel}
|
|
184
|
+
onUpdate={setFunnel}
|
|
185
|
+
fieldRegistry={sampleFieldRegistry}
|
|
186
|
+
/>
|
|
187
|
+
|
|
188
|
+
{/* Debug output */}
|
|
189
|
+
<details style={{ marginTop: '2rem', padding: '1rem', background: '#f9fafb', borderRadius: '8px' }}>
|
|
190
|
+
<summary style={{ cursor: 'pointer', fontWeight: 600 }}>Debug: Funnel State</summary>
|
|
191
|
+
<pre style={{ fontSize: '12px', overflow: 'auto', marginTop: '1rem' }}>
|
|
192
|
+
{JSON.stringify(funnel, null, 2)}
|
|
193
|
+
</pre>
|
|
194
|
+
</details>
|
|
195
|
+
</div>
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Empty state
|
|
200
|
+
export const EmptyState: Story = {
|
|
201
|
+
render: () => <InteractiveFunnelStageBuilder initialFunnel={createEmptyFunnel()} />,
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
// With sample stages
|
|
205
|
+
export const WithSampleStages: Story = {
|
|
206
|
+
render: () => <InteractiveFunnelStageBuilder initialFunnel={createSampleFunnel()} />,
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// Single stage
|
|
210
|
+
export const SingleStage: Story = {
|
|
211
|
+
render: () => {
|
|
212
|
+
const funnel = createEmptyFunnel();
|
|
213
|
+
funnel.stages = [
|
|
214
|
+
{
|
|
215
|
+
id: 'stage-1',
|
|
216
|
+
order: 0,
|
|
217
|
+
name: 'Stage 1',
|
|
218
|
+
description: '',
|
|
219
|
+
filter_logic: 'AND',
|
|
220
|
+
rules: [],
|
|
221
|
+
match_action: 'continue',
|
|
222
|
+
no_match_action: 'continue',
|
|
223
|
+
},
|
|
224
|
+
];
|
|
225
|
+
return <InteractiveFunnelStageBuilder initialFunnel={funnel} />;
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
// Multiple stages (5+)
|
|
230
|
+
export const MultipleStages: Story = {
|
|
231
|
+
render: () => {
|
|
232
|
+
const funnel = createEmptyFunnel();
|
|
233
|
+
funnel.stages = Array.from({ length: 5 }, (_, i) => ({
|
|
234
|
+
id: `stage-${i}`,
|
|
235
|
+
order: i,
|
|
236
|
+
name: `Stage ${i + 1}`,
|
|
237
|
+
description: `Description for stage ${i + 1}`,
|
|
238
|
+
filter_logic: 'AND' as const,
|
|
239
|
+
rules: [],
|
|
240
|
+
match_action: 'continue' as const,
|
|
241
|
+
no_match_action: 'continue' as const,
|
|
242
|
+
}));
|
|
243
|
+
return <InteractiveFunnelStageBuilder initialFunnel={funnel} />;
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
// Validation errors
|
|
248
|
+
export const WithValidationErrors: Story = {
|
|
249
|
+
render: () => {
|
|
250
|
+
const funnel = createEmptyFunnel();
|
|
251
|
+
funnel.stages = [
|
|
252
|
+
{
|
|
253
|
+
id: 'stage-1',
|
|
254
|
+
order: 0,
|
|
255
|
+
name: 'Duplicate Name',
|
|
256
|
+
filter_logic: 'AND',
|
|
257
|
+
rules: [],
|
|
258
|
+
match_action: 'continue',
|
|
259
|
+
no_match_action: 'continue',
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
id: 'stage-2',
|
|
263
|
+
order: 1,
|
|
264
|
+
name: 'Duplicate Name', // Duplicate!
|
|
265
|
+
filter_logic: 'AND',
|
|
266
|
+
rules: [],
|
|
267
|
+
match_action: 'continue',
|
|
268
|
+
no_match_action: 'continue',
|
|
269
|
+
},
|
|
270
|
+
];
|
|
271
|
+
return <InteractiveFunnelStageBuilder initialFunnel={funnel} />;
|
|
272
|
+
},
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
// Different action combinations
|
|
276
|
+
export const WithDifferentActions: Story = {
|
|
277
|
+
render: () => {
|
|
278
|
+
const funnel = createEmptyFunnel();
|
|
279
|
+
funnel.stages = [
|
|
280
|
+
{
|
|
281
|
+
id: 'stage-1',
|
|
282
|
+
order: 0,
|
|
283
|
+
name: 'Tag & Continue',
|
|
284
|
+
filter_logic: 'AND',
|
|
285
|
+
rules: [],
|
|
286
|
+
match_action: 'tag_continue',
|
|
287
|
+
no_match_action: 'continue',
|
|
288
|
+
match_tags: ['qualified', 'high-priority'],
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
id: 'stage-2',
|
|
292
|
+
order: 1,
|
|
293
|
+
name: 'Output',
|
|
294
|
+
filter_logic: 'OR',
|
|
295
|
+
rules: [],
|
|
296
|
+
match_action: 'output',
|
|
297
|
+
no_match_action: 'exclude',
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
id: 'stage-3',
|
|
301
|
+
order: 2,
|
|
302
|
+
name: 'Tag & Exclude',
|
|
303
|
+
filter_logic: 'AND',
|
|
304
|
+
rules: [],
|
|
305
|
+
match_action: 'continue',
|
|
306
|
+
no_match_action: 'tag_exclude',
|
|
307
|
+
no_match_tags: ['rejected', 'low-priority'],
|
|
308
|
+
},
|
|
309
|
+
];
|
|
310
|
+
return <InteractiveFunnelStageBuilder initialFunnel={funnel} />;
|
|
311
|
+
},
|
|
312
|
+
};
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FunnelStageBuilder tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
6
|
+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
7
|
+
import { FunnelStageBuilder } from './FunnelStageBuilder';
|
|
8
|
+
import { Funnel, FieldDefinition } from '../../types';
|
|
9
|
+
|
|
10
|
+
// Mock field registry
|
|
11
|
+
const mockFieldRegistry: FieldDefinition[] = [
|
|
12
|
+
{
|
|
13
|
+
name: 'firm.stage',
|
|
14
|
+
label: 'Firm Stage',
|
|
15
|
+
type: 'string',
|
|
16
|
+
operators: ['eq', 'ne', 'in'],
|
|
17
|
+
category: 'Firm',
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
name: 'firm.aum_usd',
|
|
21
|
+
label: 'AUM (USD)',
|
|
22
|
+
type: 'number',
|
|
23
|
+
operators: ['gt', 'lt', 'gte', 'lte'],
|
|
24
|
+
category: 'Firm',
|
|
25
|
+
},
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
// Create test funnel
|
|
29
|
+
function createTestFunnel(stageCount: number = 2): Funnel {
|
|
30
|
+
return {
|
|
31
|
+
id: 'funnel-1',
|
|
32
|
+
name: 'Test Funnel',
|
|
33
|
+
description: 'Test funnel',
|
|
34
|
+
status: 'draft',
|
|
35
|
+
input_type: 'contacts',
|
|
36
|
+
stages: Array.from({ length: stageCount }, (_, i) => ({
|
|
37
|
+
id: `stage-${i}`,
|
|
38
|
+
order: i,
|
|
39
|
+
name: `Stage ${i + 1}`,
|
|
40
|
+
description: '',
|
|
41
|
+
filter_logic: 'AND' as const,
|
|
42
|
+
rules: [],
|
|
43
|
+
match_action: 'continue' as const,
|
|
44
|
+
no_match_action: 'continue' as const,
|
|
45
|
+
})),
|
|
46
|
+
created_at: new Date().toISOString(),
|
|
47
|
+
updated_at: new Date().toISOString(),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('FunnelStageBuilder', () => {
|
|
52
|
+
it('renders with no stages (empty state)', () => {
|
|
53
|
+
const funnel = createTestFunnel(0);
|
|
54
|
+
const onUpdate = vi.fn();
|
|
55
|
+
|
|
56
|
+
render(
|
|
57
|
+
<FunnelStageBuilder
|
|
58
|
+
funnel={funnel}
|
|
59
|
+
onUpdate={onUpdate}
|
|
60
|
+
fieldRegistry={mockFieldRegistry}
|
|
61
|
+
/>
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
expect(screen.getByText(/No stages yet/i)).toBeInTheDocument();
|
|
65
|
+
expect(screen.getByText(/Add Stage/i)).toBeInTheDocument();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('renders with 1 stage', () => {
|
|
69
|
+
const funnel = createTestFunnel(1);
|
|
70
|
+
const onUpdate = vi.fn();
|
|
71
|
+
|
|
72
|
+
render(
|
|
73
|
+
<FunnelStageBuilder
|
|
74
|
+
funnel={funnel}
|
|
75
|
+
onUpdate={onUpdate}
|
|
76
|
+
fieldRegistry={mockFieldRegistry}
|
|
77
|
+
/>
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
// Text is split across multiple spans, so use getByText with regex
|
|
81
|
+
expect(screen.getByText(/Stage 1:/)).toBeInTheDocument();
|
|
82
|
+
expect(screen.getByText('Stage 1')).toBeInTheDocument();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('renders with multiple stages', () => {
|
|
86
|
+
const funnel = createTestFunnel(3);
|
|
87
|
+
const onUpdate = vi.fn();
|
|
88
|
+
|
|
89
|
+
render(
|
|
90
|
+
<FunnelStageBuilder
|
|
91
|
+
funnel={funnel}
|
|
92
|
+
onUpdate={onUpdate}
|
|
93
|
+
fieldRegistry={mockFieldRegistry}
|
|
94
|
+
/>
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
// Check for stage numbers (text is split across spans)
|
|
98
|
+
expect(screen.getByText(/Stage 1:/)).toBeInTheDocument();
|
|
99
|
+
expect(screen.getByText(/Stage 2:/)).toBeInTheDocument();
|
|
100
|
+
expect(screen.getByText(/Stage 3:/)).toBeInTheDocument();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('adds a new stage at the top', async () => {
|
|
104
|
+
const funnel = createTestFunnel(0);
|
|
105
|
+
const onUpdate = vi.fn();
|
|
106
|
+
|
|
107
|
+
render(
|
|
108
|
+
<FunnelStageBuilder
|
|
109
|
+
funnel={funnel}
|
|
110
|
+
onUpdate={onUpdate}
|
|
111
|
+
fieldRegistry={mockFieldRegistry}
|
|
112
|
+
/>
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
// Click add stage button
|
|
116
|
+
const addButton = screen.getByText(/Add Stage/i);
|
|
117
|
+
fireEvent.click(addButton);
|
|
118
|
+
|
|
119
|
+
await waitFor(() => {
|
|
120
|
+
expect(onUpdate).toHaveBeenCalledWith(
|
|
121
|
+
expect.objectContaining({
|
|
122
|
+
stages: expect.arrayContaining([
|
|
123
|
+
expect.objectContaining({
|
|
124
|
+
name: 'Stage 1',
|
|
125
|
+
order: 0,
|
|
126
|
+
}),
|
|
127
|
+
]),
|
|
128
|
+
})
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('adds a new stage at the bottom', async () => {
|
|
134
|
+
const funnel = createTestFunnel(2);
|
|
135
|
+
const onUpdate = vi.fn();
|
|
136
|
+
|
|
137
|
+
render(
|
|
138
|
+
<FunnelStageBuilder
|
|
139
|
+
funnel={funnel}
|
|
140
|
+
onUpdate={onUpdate}
|
|
141
|
+
fieldRegistry={mockFieldRegistry}
|
|
142
|
+
/>
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
// Click add stage below button
|
|
146
|
+
const addButton = screen.getByText(/Add Stage Below/i);
|
|
147
|
+
fireEvent.click(addButton);
|
|
148
|
+
|
|
149
|
+
await waitFor(() => {
|
|
150
|
+
expect(onUpdate).toHaveBeenCalledWith(
|
|
151
|
+
expect.objectContaining({
|
|
152
|
+
stages: expect.arrayContaining([
|
|
153
|
+
expect.objectContaining({ name: 'Stage 1', order: 0 }),
|
|
154
|
+
expect.objectContaining({ name: 'Stage 2', order: 1 }),
|
|
155
|
+
expect.objectContaining({ name: 'Stage 3', order: 2 }),
|
|
156
|
+
]),
|
|
157
|
+
})
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('removes a stage', async () => {
|
|
163
|
+
const funnel = createTestFunnel(2);
|
|
164
|
+
const onUpdate = vi.fn();
|
|
165
|
+
|
|
166
|
+
render(
|
|
167
|
+
<FunnelStageBuilder
|
|
168
|
+
funnel={funnel}
|
|
169
|
+
onUpdate={onUpdate}
|
|
170
|
+
fieldRegistry={mockFieldRegistry}
|
|
171
|
+
/>
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
// Find and click delete button for first stage
|
|
175
|
+
const deleteButtons = screen.getAllByLabelText(/Delete stage/i);
|
|
176
|
+
fireEvent.click(deleteButtons[0]);
|
|
177
|
+
|
|
178
|
+
await waitFor(() => {
|
|
179
|
+
expect(onUpdate).toHaveBeenCalledWith(
|
|
180
|
+
expect.objectContaining({
|
|
181
|
+
stages: expect.arrayContaining([
|
|
182
|
+
expect.objectContaining({ name: 'Stage 2', order: 0 }), // Reordered
|
|
183
|
+
]),
|
|
184
|
+
})
|
|
185
|
+
);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('expands and collapses stages', () => {
|
|
190
|
+
const funnel = createTestFunnel(1);
|
|
191
|
+
const onUpdate = vi.fn();
|
|
192
|
+
|
|
193
|
+
render(
|
|
194
|
+
<FunnelStageBuilder
|
|
195
|
+
funnel={funnel}
|
|
196
|
+
onUpdate={onUpdate}
|
|
197
|
+
fieldRegistry={mockFieldRegistry}
|
|
198
|
+
/>
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
// Initially expanded (default)
|
|
202
|
+
expect(screen.getByLabelText(/Stage Name/i)).toBeInTheDocument();
|
|
203
|
+
|
|
204
|
+
// Click to collapse - find button by aria-expanded
|
|
205
|
+
const titleButton = screen.getByRole('button', { expanded: true });
|
|
206
|
+
fireEvent.click(titleButton);
|
|
207
|
+
|
|
208
|
+
// Should show summary instead
|
|
209
|
+
expect(screen.queryByLabelText(/Stage Name/i)).not.toBeInTheDocument();
|
|
210
|
+
expect(screen.getByText(/Rules:/i)).toBeInTheDocument();
|
|
211
|
+
|
|
212
|
+
// Click to expand again
|
|
213
|
+
const collapsedButton = screen.getByRole('button', { expanded: false });
|
|
214
|
+
fireEvent.click(collapsedButton);
|
|
215
|
+
expect(screen.getByLabelText(/Stage Name/i)).toBeInTheDocument();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('validates stage name uniqueness', async () => {
|
|
219
|
+
const funnel = createTestFunnel(2);
|
|
220
|
+
const onUpdate = vi.fn();
|
|
221
|
+
|
|
222
|
+
render(
|
|
223
|
+
<FunnelStageBuilder
|
|
224
|
+
funnel={funnel}
|
|
225
|
+
onUpdate={onUpdate}
|
|
226
|
+
fieldRegistry={mockFieldRegistry}
|
|
227
|
+
/>
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
// Get the first stage's name input
|
|
231
|
+
const nameInputs = screen.getAllByLabelText(/Stage Name/i);
|
|
232
|
+
const firstInput = nameInputs[0];
|
|
233
|
+
|
|
234
|
+
// Try to change name to match second stage
|
|
235
|
+
fireEvent.change(firstInput, { target: { value: 'Stage 2' } });
|
|
236
|
+
|
|
237
|
+
// Wait for debounce and validation
|
|
238
|
+
await waitFor(() => {
|
|
239
|
+
expect(screen.getByText(/Stage name must be unique/i)).toBeInTheDocument();
|
|
240
|
+
}, { timeout: 500 });
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('warns when stage has no rules', () => {
|
|
244
|
+
const funnel = createTestFunnel(1);
|
|
245
|
+
const onUpdate = vi.fn();
|
|
246
|
+
|
|
247
|
+
render(
|
|
248
|
+
<FunnelStageBuilder
|
|
249
|
+
funnel={funnel}
|
|
250
|
+
onUpdate={onUpdate}
|
|
251
|
+
fieldRegistry={mockFieldRegistry}
|
|
252
|
+
/>
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
// Should show warning
|
|
256
|
+
expect(screen.getByText(/Stage has no filter rules/i)).toBeInTheDocument();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('updates stage name with debounce', async () => {
|
|
260
|
+
const funnel = createTestFunnel(1);
|
|
261
|
+
const onUpdate = vi.fn();
|
|
262
|
+
|
|
263
|
+
render(
|
|
264
|
+
<FunnelStageBuilder
|
|
265
|
+
funnel={funnel}
|
|
266
|
+
onUpdate={onUpdate}
|
|
267
|
+
fieldRegistry={mockFieldRegistry}
|
|
268
|
+
/>
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
const nameInput = screen.getByLabelText(/Stage Name/i);
|
|
272
|
+
fireEvent.change(nameInput, { target: { value: 'Updated Stage Name' } });
|
|
273
|
+
|
|
274
|
+
// Should debounce (not called immediately)
|
|
275
|
+
expect(onUpdate).not.toHaveBeenCalled();
|
|
276
|
+
|
|
277
|
+
// Wait for debounce
|
|
278
|
+
await waitFor(() => {
|
|
279
|
+
expect(onUpdate).toHaveBeenCalledWith(
|
|
280
|
+
expect.objectContaining({
|
|
281
|
+
stages: expect.arrayContaining([
|
|
282
|
+
expect.objectContaining({ name: 'Updated Stage Name' }),
|
|
283
|
+
]),
|
|
284
|
+
})
|
|
285
|
+
);
|
|
286
|
+
}, { timeout: 500 });
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('applies custom className', () => {
|
|
290
|
+
const funnel = createTestFunnel(0);
|
|
291
|
+
const onUpdate = vi.fn();
|
|
292
|
+
|
|
293
|
+
const { container } = render(
|
|
294
|
+
<FunnelStageBuilder
|
|
295
|
+
funnel={funnel}
|
|
296
|
+
onUpdate={onUpdate}
|
|
297
|
+
fieldRegistry={mockFieldRegistry}
|
|
298
|
+
className="custom-class"
|
|
299
|
+
/>
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
expect(container.querySelector('.custom-class')).toBeInTheDocument();
|
|
303
|
+
});
|
|
304
|
+
});
|