@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.
Files changed (151) hide show
  1. package/package.json +9 -31
  2. package/src/api/README.md +507 -0
  3. package/src/api/adapter.ts +106 -0
  4. package/src/api/client.test.ts +640 -0
  5. package/src/api/client.ts +385 -0
  6. package/src/api/default-adapter.ts +243 -0
  7. package/src/api/index.ts +24 -0
  8. package/src/components/FilterRuleEditor/ARCHITECTURE.md +354 -0
  9. package/src/components/FilterRuleEditor/FieldSelector.tsx +91 -0
  10. package/src/components/FilterRuleEditor/FilterRuleEditor.stories.tsx +462 -0
  11. package/src/components/FilterRuleEditor/FilterRuleEditor.test.tsx +520 -0
  12. package/src/components/FilterRuleEditor/FilterRuleEditor.tsx +225 -0
  13. package/src/components/FilterRuleEditor/LogicToggle.tsx +64 -0
  14. package/src/components/FilterRuleEditor/OperatorSelector.tsx +75 -0
  15. package/src/components/FilterRuleEditor/README.md +291 -0
  16. package/src/components/FilterRuleEditor/RuleRow.tsx +246 -0
  17. package/src/components/FilterRuleEditor/ValueInputs/BooleanValueInput.tsx +54 -0
  18. package/src/components/FilterRuleEditor/ValueInputs/ChoiceValueInput.tsx +83 -0
  19. package/src/components/FilterRuleEditor/ValueInputs/DateValueInput.tsx +70 -0
  20. package/src/components/FilterRuleEditor/ValueInputs/MultiChoiceValueInput.tsx +132 -0
  21. package/src/components/FilterRuleEditor/ValueInputs/NumberValueInput.tsx +73 -0
  22. package/src/components/FilterRuleEditor/ValueInputs/TextValueInput.tsx +50 -0
  23. package/src/components/FilterRuleEditor/ValueInputs/index.ts +12 -0
  24. package/src/components/FilterRuleEditor/constants.ts +64 -0
  25. package/src/components/FilterRuleEditor/index.ts +14 -0
  26. package/src/components/FunnelCard/DESIGN.md +447 -0
  27. package/src/components/FunnelCard/FunnelCard.stories.tsx +484 -0
  28. package/src/components/FunnelCard/FunnelCard.test.ts +257 -0
  29. package/src/components/FunnelCard/FunnelCard.test.tsx +336 -0
  30. package/src/components/FunnelCard/FunnelCard.tsx +204 -0
  31. package/src/components/FunnelCard/FunnelStats.tsx +68 -0
  32. package/src/components/FunnelCard/IMPLEMENTATION_SUMMARY.md +505 -0
  33. package/src/components/FunnelCard/INSTALLATION.md +304 -0
  34. package/src/components/FunnelCard/MatchBar.tsx +49 -0
  35. package/src/components/FunnelCard/README.md +294 -0
  36. package/src/components/FunnelCard/StageIndicator.tsx +62 -0
  37. package/src/components/FunnelCard/StatusBadge.tsx +52 -0
  38. package/src/components/FunnelCard/index.ts +14 -0
  39. package/src/components/FunnelPreview/EntityCard.tsx +72 -0
  40. package/src/components/FunnelPreview/FunnelPreview.stories.tsx +227 -0
  41. package/src/components/FunnelPreview/FunnelPreview.test.tsx +316 -0
  42. package/src/components/FunnelPreview/FunnelPreview.tsx +249 -0
  43. package/src/components/FunnelPreview/LoadingPreview.tsx +60 -0
  44. package/src/components/FunnelPreview/PreviewStats.tsx +78 -0
  45. package/src/components/FunnelPreview/README.md +337 -0
  46. package/src/components/FunnelPreview/StageBreakdown.tsx +94 -0
  47. package/src/components/FunnelPreview/example.tsx +286 -0
  48. package/src/components/FunnelPreview/index.ts +14 -0
  49. package/src/components/FunnelRunHistory/COMPONENT_SUMMARY.md +246 -0
  50. package/src/components/FunnelRunHistory/FunnelRunHistory.stories.tsx +272 -0
  51. package/src/components/FunnelRunHistory/FunnelRunHistory.test.tsx +323 -0
  52. package/src/components/FunnelRunHistory/FunnelRunHistory.tsx +329 -0
  53. package/src/components/FunnelRunHistory/README.md +325 -0
  54. package/src/components/FunnelRunHistory/RunActions.tsx +168 -0
  55. package/src/components/FunnelRunHistory/RunDetailsModal.tsx +221 -0
  56. package/src/components/FunnelRunHistory/RunFilters.tsx +128 -0
  57. package/src/components/FunnelRunHistory/RunRow.tsx +122 -0
  58. package/src/components/FunnelRunHistory/RunStatusBadge.tsx +75 -0
  59. package/src/components/FunnelRunHistory/StageBreakdownList.tsx +110 -0
  60. package/src/components/FunnelRunHistory/index.ts +51 -0
  61. package/src/components/FunnelRunHistory/types.ts +40 -0
  62. package/src/components/FunnelRunHistory/utils.test.ts +126 -0
  63. package/src/components/FunnelRunHistory/utils.ts +100 -0
  64. package/src/components/FunnelStageBuilder/AddStageButton.tsx +52 -0
  65. package/src/components/FunnelStageBuilder/FunnelStageBuilder.css +413 -0
  66. package/src/components/FunnelStageBuilder/FunnelStageBuilder.stories.tsx +312 -0
  67. package/src/components/FunnelStageBuilder/FunnelStageBuilder.test.tsx +304 -0
  68. package/src/components/FunnelStageBuilder/FunnelStageBuilder.tsx +321 -0
  69. package/src/components/FunnelStageBuilder/README.md +341 -0
  70. package/src/components/FunnelStageBuilder/StageActions.test.tsx +205 -0
  71. package/src/components/FunnelStageBuilder/StageActions.tsx +126 -0
  72. package/src/components/FunnelStageBuilder/StageCard.tsx +202 -0
  73. package/src/components/FunnelStageBuilder/StageForm.tsx +262 -0
  74. package/src/components/FunnelStageBuilder/TagInput.test.tsx +178 -0
  75. package/src/components/FunnelStageBuilder/TagInput.tsx +129 -0
  76. package/src/components/FunnelStageBuilder/index.ts +21 -0
  77. package/src/components/FunnelVisualFlow/FlowLegend.tsx +77 -0
  78. package/{dist/components/index.css → src/components/FunnelVisualFlow/FunnelVisualFlow.css} +89 -13
  79. package/src/components/FunnelVisualFlow/FunnelVisualFlow.stories.tsx +254 -0
  80. package/src/components/FunnelVisualFlow/FunnelVisualFlow.test.tsx +208 -0
  81. package/src/components/FunnelVisualFlow/FunnelVisualFlow.tsx +229 -0
  82. package/src/components/FunnelVisualFlow/README.md +323 -0
  83. package/src/components/FunnelVisualFlow/StageNode.tsx +188 -0
  84. package/src/components/FunnelVisualFlow/example.tsx +227 -0
  85. package/src/components/FunnelVisualFlow/index.ts +10 -0
  86. package/src/components/index.ts +102 -0
  87. package/src/core/README.md +307 -0
  88. package/src/core/engine.test.ts +1087 -0
  89. package/src/core/engine.ts +329 -0
  90. package/src/core/evaluator.example.ts +353 -0
  91. package/src/core/evaluator.test.ts +639 -0
  92. package/src/core/evaluator.ts +261 -0
  93. package/src/core/field-resolver.example.ts +175 -0
  94. package/src/core/field-resolver.test.ts +541 -0
  95. package/src/core/field-resolver.ts +247 -0
  96. package/src/core/index.ts +34 -0
  97. package/src/core/operators.test.ts +539 -0
  98. package/src/core/operators.ts +241 -0
  99. package/src/hooks/index.ts +5 -0
  100. package/src/hooks/useDebouncedValue.ts +28 -0
  101. package/src/index.ts +155 -0
  102. package/src/store/README.md +342 -0
  103. package/src/store/create-funnel-store.test.ts +686 -0
  104. package/src/store/create-funnel-store.ts +538 -0
  105. package/src/store/index.ts +9 -0
  106. package/src/store/types.ts +294 -0
  107. package/src/stories/CrossDomain.stories.tsx +149 -0
  108. package/src/stories/Welcome.stories.tsx +81 -0
  109. package/src/stories/demo-data/index.ts +3 -0
  110. package/src/stories/demo-data/investors.ts +216 -0
  111. package/src/stories/demo-data/leads.ts +223 -0
  112. package/src/stories/demo-data/recipes.ts +217 -0
  113. package/src/test/setup.ts +5 -0
  114. package/src/types/index.ts +843 -0
  115. package/dist/client-3ESO2NHy.d.ts +0 -310
  116. package/dist/client-CZu03ACp.d.cts +0 -310
  117. package/dist/components/index.cjs +0 -3243
  118. package/dist/components/index.cjs.map +0 -1
  119. package/dist/components/index.css.map +0 -1
  120. package/dist/components/index.d.cts +0 -726
  121. package/dist/components/index.d.ts +0 -726
  122. package/dist/components/index.js +0 -3196
  123. package/dist/components/index.js.map +0 -1
  124. package/dist/core/index.cjs +0 -500
  125. package/dist/core/index.cjs.map +0 -1
  126. package/dist/core/index.d.cts +0 -359
  127. package/dist/core/index.d.ts +0 -359
  128. package/dist/core/index.js +0 -486
  129. package/dist/core/index.js.map +0 -1
  130. package/dist/hooks/index.cjs +0 -21
  131. package/dist/hooks/index.cjs.map +0 -1
  132. package/dist/hooks/index.d.cts +0 -11
  133. package/dist/hooks/index.d.ts +0 -11
  134. package/dist/hooks/index.js +0 -19
  135. package/dist/hooks/index.js.map +0 -1
  136. package/dist/index-BGDEXbuz.d.cts +0 -434
  137. package/dist/index-BGDEXbuz.d.ts +0 -434
  138. package/dist/index.cjs +0 -4499
  139. package/dist/index.cjs.map +0 -1
  140. package/dist/index.css +0 -198
  141. package/dist/index.css.map +0 -1
  142. package/dist/index.d.cts +0 -99
  143. package/dist/index.d.ts +0 -99
  144. package/dist/index.js +0 -4421
  145. package/dist/index.js.map +0 -1
  146. package/dist/store/index.cjs +0 -391
  147. package/dist/store/index.cjs.map +0 -1
  148. package/dist/store/index.d.cts +0 -225
  149. package/dist/store/index.d.ts +0 -225
  150. package/dist/store/index.js +0 -388
  151. 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
+ });