@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,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FunnelCard Component Tests
|
|
3
|
+
*
|
|
4
|
+
* Test Coverage:
|
|
5
|
+
* - Rendering with different funnel states (active, draft, paused, archived)
|
|
6
|
+
* - Rendering with different stage counts (0, 1, 5, 10+)
|
|
7
|
+
* - Rendering with/without latest run data
|
|
8
|
+
* - Callback invocations (onViewFlow, onEdit)
|
|
9
|
+
* - Accessibility attributes
|
|
10
|
+
* - Edge cases (empty description, no stages, failed run)
|
|
11
|
+
*
|
|
12
|
+
* NOTE: These tests require React and @testing-library/react to run.
|
|
13
|
+
* Install with: npm install --save-dev react @types/react @testing-library/react @testing-library/jest-dom
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
17
|
+
import { Funnel, FunnelRun, FunnelStatus } from '../../types';
|
|
18
|
+
|
|
19
|
+
// Test data factories
|
|
20
|
+
function createFunnel(overrides?: Partial<Funnel>): Funnel {
|
|
21
|
+
return {
|
|
22
|
+
id: 'funnel-1',
|
|
23
|
+
name: 'Test Funnel',
|
|
24
|
+
description: 'Test funnel description',
|
|
25
|
+
status: 'active',
|
|
26
|
+
input_type: 'any',
|
|
27
|
+
stages: [
|
|
28
|
+
{
|
|
29
|
+
id: 'stage-1',
|
|
30
|
+
order: 0,
|
|
31
|
+
name: 'Stage 1',
|
|
32
|
+
description: 'First stage',
|
|
33
|
+
filter_logic: 'AND',
|
|
34
|
+
rules: [
|
|
35
|
+
{ field_path: 'name', operator: 'eq', value: 'test' }
|
|
36
|
+
],
|
|
37
|
+
match_action: 'continue',
|
|
38
|
+
no_match_action: 'exclude',
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: 'stage-2',
|
|
42
|
+
order: 1,
|
|
43
|
+
name: 'Stage 2',
|
|
44
|
+
description: 'Second stage',
|
|
45
|
+
filter_logic: 'OR',
|
|
46
|
+
rules: [
|
|
47
|
+
{ field_path: 'value', operator: 'gt', value: 100 },
|
|
48
|
+
{ field_path: 'type', operator: 'in', value: ['A', 'B'] },
|
|
49
|
+
{ field_path: 'active', operator: 'is_true', value: null },
|
|
50
|
+
],
|
|
51
|
+
match_action: 'output',
|
|
52
|
+
no_match_action: 'exclude',
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
created_at: '2024-01-01T00:00:00Z',
|
|
56
|
+
updated_at: '2024-01-01T00:00:00Z',
|
|
57
|
+
...overrides,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function createFunnelRun(overrides?: Partial<FunnelRun>): FunnelRun {
|
|
62
|
+
return {
|
|
63
|
+
id: 'run-1',
|
|
64
|
+
funnel_id: 'funnel-1',
|
|
65
|
+
status: 'completed',
|
|
66
|
+
trigger_type: 'manual',
|
|
67
|
+
started_at: '2024-01-01T00:00:00Z',
|
|
68
|
+
completed_at: '2024-01-01T00:01:00Z',
|
|
69
|
+
duration_ms: 60000,
|
|
70
|
+
total_input: 83061,
|
|
71
|
+
total_matched: 235,
|
|
72
|
+
total_excluded: 82826,
|
|
73
|
+
total_tagged: 235,
|
|
74
|
+
stage_stats: {},
|
|
75
|
+
...overrides,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe('FunnelCard', () => {
|
|
80
|
+
describe('Test Data Factories', () => {
|
|
81
|
+
it('creates valid funnel', () => {
|
|
82
|
+
const funnel = createFunnel();
|
|
83
|
+
|
|
84
|
+
expect(funnel.id).toBe('funnel-1');
|
|
85
|
+
expect(funnel.name).toBe('Test Funnel');
|
|
86
|
+
expect(funnel.status).toBe('active');
|
|
87
|
+
expect(funnel.stages).toHaveLength(2);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('creates funnel with overrides', () => {
|
|
91
|
+
const funnel = createFunnel({ name: 'Custom Funnel', status: 'draft' });
|
|
92
|
+
|
|
93
|
+
expect(funnel.name).toBe('Custom Funnel');
|
|
94
|
+
expect(funnel.status).toBe('draft');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('creates valid funnel run', () => {
|
|
98
|
+
const run = createFunnelRun();
|
|
99
|
+
|
|
100
|
+
expect(run.id).toBe('run-1');
|
|
101
|
+
expect(run.status).toBe('completed');
|
|
102
|
+
expect(run.total_input).toBe(83061);
|
|
103
|
+
expect(run.total_matched).toBe(235);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('creates funnel run with overrides', () => {
|
|
107
|
+
const run = createFunnelRun({ total_matched: 1000 });
|
|
108
|
+
|
|
109
|
+
expect(run.total_matched).toBe(1000);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('Funnel Validation', () => {
|
|
114
|
+
it('validates funnel with 0 stages', () => {
|
|
115
|
+
const funnel = createFunnel({ stages: [] });
|
|
116
|
+
|
|
117
|
+
expect(funnel.stages).toHaveLength(0);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('validates funnel with 5 stages', () => {
|
|
121
|
+
const stages = Array.from({ length: 5 }, (_, i) => ({
|
|
122
|
+
id: `stage-${i}`,
|
|
123
|
+
order: i,
|
|
124
|
+
name: `Stage ${i + 1}`,
|
|
125
|
+
filter_logic: 'AND' as const,
|
|
126
|
+
rules: [{ field_path: 'x', operator: 'eq' as const, value: i }],
|
|
127
|
+
match_action: 'continue' as const,
|
|
128
|
+
no_match_action: 'exclude' as const,
|
|
129
|
+
}));
|
|
130
|
+
const funnel = createFunnel({ stages });
|
|
131
|
+
|
|
132
|
+
expect(funnel.stages).toHaveLength(5);
|
|
133
|
+
expect(funnel.stages[0].order).toBe(0);
|
|
134
|
+
expect(funnel.stages[4].order).toBe(4);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('validates different status values', () => {
|
|
138
|
+
const statuses: FunnelStatus[] = ['active', 'draft', 'paused', 'archived'];
|
|
139
|
+
|
|
140
|
+
statuses.forEach((status) => {
|
|
141
|
+
const funnel = createFunnel({ status });
|
|
142
|
+
expect(funnel.status).toBe(status);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe('Run Stats Calculation', () => {
|
|
148
|
+
it('calculates match percentage correctly', () => {
|
|
149
|
+
const run = createFunnelRun();
|
|
150
|
+
const percentage = (run.total_matched / run.total_input) * 100;
|
|
151
|
+
|
|
152
|
+
expect(percentage).toBeCloseTo(0.28, 2); // 235/83061 ≈ 0.28%
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('handles zero input', () => {
|
|
156
|
+
const run = createFunnelRun({ total_input: 0, total_matched: 0 });
|
|
157
|
+
const percentage = run.total_input > 0 ? (run.total_matched / run.total_input) * 100 : 0;
|
|
158
|
+
|
|
159
|
+
expect(percentage).toBe(0);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('handles 100% match rate', () => {
|
|
163
|
+
const run = createFunnelRun({ total_input: 1000, total_matched: 1000, total_excluded: 0 });
|
|
164
|
+
const percentage = (run.total_matched / run.total_input) * 100;
|
|
165
|
+
|
|
166
|
+
expect(percentage).toBe(100);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe('Stage Rule Counting', () => {
|
|
171
|
+
it('counts rules correctly', () => {
|
|
172
|
+
const funnel = createFunnel();
|
|
173
|
+
|
|
174
|
+
expect(funnel.stages[0].rules).toHaveLength(1);
|
|
175
|
+
expect(funnel.stages[1].rules).toHaveLength(3);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('handles stages with no rules', () => {
|
|
179
|
+
const funnel = createFunnel({
|
|
180
|
+
stages: [
|
|
181
|
+
{
|
|
182
|
+
id: 'stage-1',
|
|
183
|
+
order: 0,
|
|
184
|
+
name: 'Empty Stage',
|
|
185
|
+
filter_logic: 'AND',
|
|
186
|
+
rules: [],
|
|
187
|
+
match_action: 'continue',
|
|
188
|
+
no_match_action: 'exclude',
|
|
189
|
+
},
|
|
190
|
+
],
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
expect(funnel.stages[0].rules).toHaveLength(0);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe('Run Status Validation', () => {
|
|
198
|
+
it('validates completed run', () => {
|
|
199
|
+
const run = createFunnelRun({ status: 'completed' });
|
|
200
|
+
|
|
201
|
+
expect(run.status).toBe('completed');
|
|
202
|
+
expect(run.completed_at).toBeDefined();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('validates running state', () => {
|
|
206
|
+
const run = createFunnelRun({ status: 'running', completed_at: undefined });
|
|
207
|
+
|
|
208
|
+
expect(run.status).toBe('running');
|
|
209
|
+
expect(run.completed_at).toBeUndefined();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('validates failed run', () => {
|
|
213
|
+
const run = createFunnelRun({ status: 'failed', error: 'Database error' });
|
|
214
|
+
|
|
215
|
+
expect(run.status).toBe('failed');
|
|
216
|
+
expect(run.error).toBe('Database error');
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
describe('Edge Cases', () => {
|
|
221
|
+
it('handles very long funnel names', () => {
|
|
222
|
+
const longName = 'This is a very long funnel name that should still be valid';
|
|
223
|
+
const funnel = createFunnel({ name: longName });
|
|
224
|
+
|
|
225
|
+
expect(funnel.name).toBe(longName);
|
|
226
|
+
expect(funnel.name.length).toBeGreaterThan(50);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('handles undefined description', () => {
|
|
230
|
+
const funnel = createFunnel({ description: undefined });
|
|
231
|
+
|
|
232
|
+
expect(funnel.description).toBeUndefined();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('handles large numbers in stats', () => {
|
|
236
|
+
const run = createFunnelRun({
|
|
237
|
+
total_input: 10000000,
|
|
238
|
+
total_matched: 500000,
|
|
239
|
+
total_excluded: 9500000,
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
expect(run.total_input).toBe(10000000);
|
|
243
|
+
expect(run.total_matched + run.total_excluded).toBe(run.total_input);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('validates stats add up correctly', () => {
|
|
247
|
+
const run = createFunnelRun();
|
|
248
|
+
|
|
249
|
+
expect(run.total_matched + run.total_excluded).toBe(run.total_input);
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// TODO: Add React component tests when React is installed
|
|
255
|
+
// Import these and uncomment when React dependencies are added:
|
|
256
|
+
// import { render, screen, fireEvent } from '@testing-library/react';
|
|
257
|
+
// import { FunnelCard } from './FunnelCard';
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FunnelCard Component Tests
|
|
3
|
+
*
|
|
4
|
+
* Test Coverage:
|
|
5
|
+
* - Rendering with different funnel states (active, draft, paused, archived)
|
|
6
|
+
* - Rendering with different stage counts (0, 1, 5, 10+)
|
|
7
|
+
* - Rendering with/without latest run data
|
|
8
|
+
* - Callback invocations (onViewFlow, onEdit)
|
|
9
|
+
* - Accessibility attributes
|
|
10
|
+
* - Edge cases (empty description, no stages, failed run)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
14
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
15
|
+
import { FunnelCard } from './FunnelCard';
|
|
16
|
+
import { Funnel, FunnelRun, FunnelStatus } from '../../types';
|
|
17
|
+
|
|
18
|
+
// Test data factories
|
|
19
|
+
function createFunnel(overrides?: Partial<Funnel>): Funnel {
|
|
20
|
+
return {
|
|
21
|
+
id: 'funnel-1',
|
|
22
|
+
name: 'Test Funnel',
|
|
23
|
+
description: 'Test funnel description',
|
|
24
|
+
status: 'active',
|
|
25
|
+
input_type: 'any',
|
|
26
|
+
stages: [
|
|
27
|
+
{
|
|
28
|
+
id: 'stage-1',
|
|
29
|
+
order: 0,
|
|
30
|
+
name: 'Stage 1',
|
|
31
|
+
description: 'First stage',
|
|
32
|
+
filter_logic: 'AND',
|
|
33
|
+
rules: [
|
|
34
|
+
{ field_path: 'name', operator: 'eq', value: 'test' }
|
|
35
|
+
],
|
|
36
|
+
match_action: 'continue',
|
|
37
|
+
no_match_action: 'exclude',
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: 'stage-2',
|
|
41
|
+
order: 1,
|
|
42
|
+
name: 'Stage 2',
|
|
43
|
+
description: 'Second stage',
|
|
44
|
+
filter_logic: 'OR',
|
|
45
|
+
rules: [
|
|
46
|
+
{ field_path: 'value', operator: 'gt', value: 100 },
|
|
47
|
+
{ field_path: 'type', operator: 'in', value: ['A', 'B'] },
|
|
48
|
+
{ field_path: 'active', operator: 'is_true', value: null },
|
|
49
|
+
],
|
|
50
|
+
match_action: 'output',
|
|
51
|
+
no_match_action: 'exclude',
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
created_at: '2024-01-01T00:00:00Z',
|
|
55
|
+
updated_at: '2024-01-01T00:00:00Z',
|
|
56
|
+
...overrides,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function createFunnelRun(overrides?: Partial<FunnelRun>): FunnelRun {
|
|
61
|
+
return {
|
|
62
|
+
id: 'run-1',
|
|
63
|
+
funnel_id: 'funnel-1',
|
|
64
|
+
status: 'completed',
|
|
65
|
+
trigger_type: 'manual',
|
|
66
|
+
started_at: '2024-01-01T00:00:00Z',
|
|
67
|
+
completed_at: '2024-01-01T00:01:00Z',
|
|
68
|
+
duration_ms: 60000,
|
|
69
|
+
total_input: 83061,
|
|
70
|
+
total_matched: 235,
|
|
71
|
+
total_excluded: 82826,
|
|
72
|
+
total_tagged: 235,
|
|
73
|
+
stage_stats: {},
|
|
74
|
+
...overrides,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe('FunnelCard', () => {
|
|
79
|
+
describe('Basic Rendering', () => {
|
|
80
|
+
it('renders funnel name and description', () => {
|
|
81
|
+
const funnel = createFunnel();
|
|
82
|
+
render(<FunnelCard funnel={funnel} />);
|
|
83
|
+
|
|
84
|
+
expect(screen.getByText('Test Funnel')).toBeInTheDocument();
|
|
85
|
+
expect(screen.getByText('Test funnel description')).toBeInTheDocument();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('renders without description', () => {
|
|
89
|
+
const funnel = createFunnel({ description: undefined });
|
|
90
|
+
render(<FunnelCard funnel={funnel} />);
|
|
91
|
+
|
|
92
|
+
expect(screen.getByText('Test Funnel')).toBeInTheDocument();
|
|
93
|
+
expect(screen.queryByText('Test funnel description')).not.toBeInTheDocument();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('renders all stages', () => {
|
|
97
|
+
const funnel = createFunnel();
|
|
98
|
+
render(<FunnelCard funnel={funnel} />);
|
|
99
|
+
|
|
100
|
+
expect(screen.getByText('Stage 1')).toBeInTheDocument();
|
|
101
|
+
expect(screen.getByText('Stage 2')).toBeInTheDocument();
|
|
102
|
+
expect(screen.getByText('1 rule')).toBeInTheDocument();
|
|
103
|
+
expect(screen.getByText('3 rules')).toBeInTheDocument();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('renders empty state for funnel with no stages', () => {
|
|
107
|
+
const funnel = createFunnel({ stages: [] });
|
|
108
|
+
render(<FunnelCard funnel={funnel} />);
|
|
109
|
+
|
|
110
|
+
expect(screen.getByText('No stages defined')).toBeInTheDocument();
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('Status Badge', () => {
|
|
115
|
+
const statuses: FunnelStatus[] = ['active', 'draft', 'paused', 'archived'];
|
|
116
|
+
|
|
117
|
+
it.each(statuses)('renders %s status badge', (status) => {
|
|
118
|
+
const funnel = createFunnel({ status });
|
|
119
|
+
render(<FunnelCard funnel={funnel} />);
|
|
120
|
+
|
|
121
|
+
expect(screen.getByText(status.toUpperCase())).toBeInTheDocument();
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('Latest Run Data', () => {
|
|
126
|
+
it('renders stats when run is completed', () => {
|
|
127
|
+
const funnel = createFunnel();
|
|
128
|
+
const latestRun = createFunnelRun();
|
|
129
|
+
render(<FunnelCard funnel={funnel} latestRun={latestRun} />);
|
|
130
|
+
|
|
131
|
+
expect(screen.getByText('83,061')).toBeInTheDocument(); // INPUT
|
|
132
|
+
expect(screen.getByText('235')).toBeInTheDocument(); // MATCHED
|
|
133
|
+
expect(screen.getByText('82,826')).toBeInTheDocument(); // EXCLUDED
|
|
134
|
+
expect(screen.getByText('235 matched')).toBeInTheDocument(); // Match bar label
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('renders "No runs yet" when no run data', () => {
|
|
138
|
+
const funnel = createFunnel();
|
|
139
|
+
render(<FunnelCard funnel={funnel} />);
|
|
140
|
+
|
|
141
|
+
expect(screen.getByText('No runs yet')).toBeInTheDocument();
|
|
142
|
+
expect(screen.queryByText('INPUT')).not.toBeInTheDocument();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('renders "Running..." for in-progress run', () => {
|
|
146
|
+
const funnel = createFunnel();
|
|
147
|
+
const latestRun = createFunnelRun({ status: 'running' });
|
|
148
|
+
render(<FunnelCard funnel={funnel} latestRun={latestRun} />);
|
|
149
|
+
|
|
150
|
+
expect(screen.getByText('Running...')).toBeInTheDocument();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('renders "Last run failed" for failed run', () => {
|
|
154
|
+
const funnel = createFunnel();
|
|
155
|
+
const latestRun = createFunnelRun({ status: 'failed' });
|
|
156
|
+
render(<FunnelCard funnel={funnel} latestRun={latestRun} />);
|
|
157
|
+
|
|
158
|
+
expect(screen.getByText('Last run failed')).toBeInTheDocument();
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe('Stage Counts', () => {
|
|
163
|
+
it('renders funnel with 1 stage', () => {
|
|
164
|
+
const funnel = createFunnel({
|
|
165
|
+
stages: [
|
|
166
|
+
{
|
|
167
|
+
id: 'stage-1',
|
|
168
|
+
order: 0,
|
|
169
|
+
name: 'Only Stage',
|
|
170
|
+
filter_logic: 'AND',
|
|
171
|
+
rules: [{ field_path: 'x', operator: 'eq', value: 1 }],
|
|
172
|
+
match_action: 'output',
|
|
173
|
+
no_match_action: 'exclude',
|
|
174
|
+
},
|
|
175
|
+
],
|
|
176
|
+
});
|
|
177
|
+
render(<FunnelCard funnel={funnel} />);
|
|
178
|
+
|
|
179
|
+
expect(screen.getByText('Only Stage')).toBeInTheDocument();
|
|
180
|
+
expect(screen.getByText('1 rule')).toBeInTheDocument();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('renders funnel with 5 stages', () => {
|
|
184
|
+
const stages = Array.from({ length: 5 }, (_, i) => ({
|
|
185
|
+
id: `stage-${i}`,
|
|
186
|
+
order: i,
|
|
187
|
+
name: `Stage ${i + 1}`,
|
|
188
|
+
filter_logic: 'AND' as const,
|
|
189
|
+
rules: [{ field_path: 'x', operator: 'eq' as const, value: i }],
|
|
190
|
+
match_action: 'continue' as const,
|
|
191
|
+
no_match_action: 'exclude' as const,
|
|
192
|
+
}));
|
|
193
|
+
const funnel = createFunnel({ stages });
|
|
194
|
+
render(<FunnelCard funnel={funnel} />);
|
|
195
|
+
|
|
196
|
+
stages.forEach((stage) => {
|
|
197
|
+
expect(screen.getByText(stage.name)).toBeInTheDocument();
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('renders funnel with 10+ stages', () => {
|
|
202
|
+
const stages = Array.from({ length: 15 }, (_, i) => ({
|
|
203
|
+
id: `stage-${i}`,
|
|
204
|
+
order: i,
|
|
205
|
+
name: `Stage ${i + 1}`,
|
|
206
|
+
filter_logic: 'AND' as const,
|
|
207
|
+
rules: [{ field_path: 'x', operator: 'eq' as const, value: i }],
|
|
208
|
+
match_action: 'continue' as const,
|
|
209
|
+
no_match_action: 'exclude' as const,
|
|
210
|
+
}));
|
|
211
|
+
const funnel = createFunnel({ stages });
|
|
212
|
+
render(<FunnelCard funnel={funnel} />);
|
|
213
|
+
|
|
214
|
+
// Check first and last stage
|
|
215
|
+
expect(screen.getByText('Stage 1')).toBeInTheDocument();
|
|
216
|
+
expect(screen.getByText('Stage 15')).toBeInTheDocument();
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
describe('Callbacks', () => {
|
|
221
|
+
it('calls onViewFlow when button clicked', () => {
|
|
222
|
+
const onViewFlow = vi.fn();
|
|
223
|
+
const funnel = createFunnel();
|
|
224
|
+
render(<FunnelCard funnel={funnel} onViewFlow={onViewFlow} />);
|
|
225
|
+
|
|
226
|
+
const button = screen.getByRole('button', { name: /view flow/i });
|
|
227
|
+
fireEvent.click(button);
|
|
228
|
+
|
|
229
|
+
expect(onViewFlow).toHaveBeenCalledTimes(1);
|
|
230
|
+
expect(onViewFlow).toHaveBeenCalledWith(funnel);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('does not error when onViewFlow is undefined', () => {
|
|
234
|
+
const funnel = createFunnel();
|
|
235
|
+
render(<FunnelCard funnel={funnel} />);
|
|
236
|
+
|
|
237
|
+
const button = screen.getByRole('button', { name: /view flow/i });
|
|
238
|
+
expect(() => fireEvent.click(button)).not.toThrow();
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
describe('Accessibility', () => {
|
|
243
|
+
it('has proper ARIA labels', () => {
|
|
244
|
+
const funnel = createFunnel();
|
|
245
|
+
const latestRun = createFunnelRun();
|
|
246
|
+
render(<FunnelCard funnel={funnel} latestRun={latestRun} />);
|
|
247
|
+
|
|
248
|
+
expect(screen.getByRole('article')).toHaveAttribute(
|
|
249
|
+
'aria-label',
|
|
250
|
+
'Funnel: Test Funnel'
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
expect(screen.getByLabelText(/funnel stages/i)).toBeInTheDocument();
|
|
254
|
+
expect(screen.getByLabelText(/match results/i)).toBeInTheDocument();
|
|
255
|
+
expect(screen.getByLabelText(/funnel statistics/i)).toBeInTheDocument();
|
|
256
|
+
|
|
257
|
+
const button = screen.getByRole('button');
|
|
258
|
+
expect(button).toHaveAttribute(
|
|
259
|
+
'aria-label',
|
|
260
|
+
'View flow details for Test Funnel'
|
|
261
|
+
);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('has proper progressbar role for match bar', () => {
|
|
265
|
+
const funnel = createFunnel();
|
|
266
|
+
const latestRun = createFunnelRun();
|
|
267
|
+
render(<FunnelCard funnel={funnel} latestRun={latestRun} />);
|
|
268
|
+
|
|
269
|
+
const progressBar = screen.getByRole('progressbar');
|
|
270
|
+
expect(progressBar).toHaveAttribute('aria-valuemin', '0');
|
|
271
|
+
expect(progressBar).toHaveAttribute('aria-valuemax', '100');
|
|
272
|
+
expect(progressBar).toHaveAttribute('aria-label', '235 of 83061 matched');
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
describe('Custom className', () => {
|
|
277
|
+
it('applies custom className', () => {
|
|
278
|
+
const funnel = createFunnel();
|
|
279
|
+
const { container } = render(
|
|
280
|
+
<FunnelCard funnel={funnel} className="custom-class" />
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
const article = container.querySelector('article');
|
|
284
|
+
expect(article).toHaveClass('custom-class');
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
describe('Edge Cases', () => {
|
|
289
|
+
it('handles zero matched entities', () => {
|
|
290
|
+
const funnel = createFunnel();
|
|
291
|
+
const latestRun = createFunnelRun({
|
|
292
|
+
total_matched: 0,
|
|
293
|
+
total_excluded: 100,
|
|
294
|
+
});
|
|
295
|
+
render(<FunnelCard funnel={funnel} latestRun={latestRun} />);
|
|
296
|
+
|
|
297
|
+
expect(screen.getByText('0 matched')).toBeInTheDocument();
|
|
298
|
+
expect(screen.getByText('100')).toBeInTheDocument(); // EXCLUDED
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('handles zero input entities', () => {
|
|
302
|
+
const funnel = createFunnel();
|
|
303
|
+
const latestRun = createFunnelRun({
|
|
304
|
+
total_input: 0,
|
|
305
|
+
total_matched: 0,
|
|
306
|
+
total_excluded: 0,
|
|
307
|
+
});
|
|
308
|
+
render(<FunnelCard funnel={funnel} latestRun={latestRun} />);
|
|
309
|
+
|
|
310
|
+
expect(screen.getByText('0 matched')).toBeInTheDocument();
|
|
311
|
+
expect(screen.getAllByText('0')).toHaveLength(3); // INPUT, MATCHED, EXCLUDED
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('handles long funnel names', () => {
|
|
315
|
+
const funnel = createFunnel({
|
|
316
|
+
name: 'This is a very long funnel name that should still render correctly without breaking the layout',
|
|
317
|
+
});
|
|
318
|
+
render(<FunnelCard funnel={funnel} />);
|
|
319
|
+
|
|
320
|
+
expect(
|
|
321
|
+
screen.getByText(
|
|
322
|
+
'This is a very long funnel name that should still render correctly without breaking the layout'
|
|
323
|
+
)
|
|
324
|
+
).toBeInTheDocument();
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('handles long descriptions', () => {
|
|
328
|
+
const funnel = createFunnel({
|
|
329
|
+
description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.',
|
|
330
|
+
});
|
|
331
|
+
render(<FunnelCard funnel={funnel} />);
|
|
332
|
+
|
|
333
|
+
expect(screen.getByText(/Lorem ipsum dolor/)).toBeInTheDocument();
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
});
|