@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,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FunnelRunHistory Tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
6
|
+
import { FunnelRun, FunnelRunStatus, StageStats } from '../../types';
|
|
7
|
+
import { FunnelApiClient } from '../../api/client';
|
|
8
|
+
|
|
9
|
+
// Mock API client
|
|
10
|
+
const createMockApiClient = (runs: FunnelRun[] = []) => {
|
|
11
|
+
return {
|
|
12
|
+
getFunnelRuns: vi.fn().mockResolvedValue({
|
|
13
|
+
count: runs.length,
|
|
14
|
+
next: null,
|
|
15
|
+
previous: null,
|
|
16
|
+
results: runs,
|
|
17
|
+
}),
|
|
18
|
+
runFunnel: vi.fn().mockResolvedValue({
|
|
19
|
+
id: 'new-run-id',
|
|
20
|
+
status: 'pending',
|
|
21
|
+
}),
|
|
22
|
+
cancelFunnelRun: vi.fn().mockResolvedValue({
|
|
23
|
+
id: 'run-id',
|
|
24
|
+
status: 'cancelled',
|
|
25
|
+
}),
|
|
26
|
+
} as unknown as FunnelApiClient;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Helper to create mock run
|
|
30
|
+
const createMockRun = (
|
|
31
|
+
overrides?: Partial<FunnelRun>
|
|
32
|
+
): FunnelRun => {
|
|
33
|
+
const stageStats: Record<string, StageStats> = {
|
|
34
|
+
'stage-1': {
|
|
35
|
+
stage_id: 'stage-1',
|
|
36
|
+
stage_name: 'High ICP Score',
|
|
37
|
+
input_count: 1000,
|
|
38
|
+
matched_count: 500,
|
|
39
|
+
not_matched_count: 500,
|
|
40
|
+
excluded_count: 0,
|
|
41
|
+
tagged_count: 0,
|
|
42
|
+
continued_count: 500,
|
|
43
|
+
duration_ms: 1000,
|
|
44
|
+
},
|
|
45
|
+
'stage-2': {
|
|
46
|
+
stage_id: 'stage-2',
|
|
47
|
+
stage_name: 'Frontend Stack',
|
|
48
|
+
input_count: 500,
|
|
49
|
+
matched_count: 350,
|
|
50
|
+
not_matched_count: 150,
|
|
51
|
+
excluded_count: 0,
|
|
52
|
+
tagged_count: 0,
|
|
53
|
+
continued_count: 350,
|
|
54
|
+
duration_ms: 800,
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
id: 'run-123',
|
|
60
|
+
funnel_id: 'funnel-456',
|
|
61
|
+
status: 'completed',
|
|
62
|
+
trigger_type: 'manual',
|
|
63
|
+
triggered_by: 'user-789',
|
|
64
|
+
started_at: new Date().toISOString(),
|
|
65
|
+
completed_at: new Date().toISOString(),
|
|
66
|
+
duration_ms: 2300,
|
|
67
|
+
total_input: 1000,
|
|
68
|
+
total_matched: 350,
|
|
69
|
+
total_excluded: 150,
|
|
70
|
+
total_tagged: 0,
|
|
71
|
+
stage_stats: stageStats,
|
|
72
|
+
...overrides,
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
describe('FunnelRunHistory Component Logic', () => {
|
|
77
|
+
let mockApiClient: FunnelApiClient;
|
|
78
|
+
|
|
79
|
+
beforeEach(() => {
|
|
80
|
+
mockApiClient = createMockApiClient([]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('loadRuns', () => {
|
|
84
|
+
it('should fetch runs from API', async () => {
|
|
85
|
+
const runs = [createMockRun()];
|
|
86
|
+
mockApiClient = createMockApiClient(runs);
|
|
87
|
+
|
|
88
|
+
const getFunnelRuns = mockApiClient.getFunnelRuns as any;
|
|
89
|
+
await getFunnelRuns('funnel-456');
|
|
90
|
+
|
|
91
|
+
expect(getFunnelRuns).toHaveBeenCalledWith('funnel-456');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('should apply status filter', async () => {
|
|
95
|
+
const getFunnelRuns = mockApiClient.getFunnelRuns as any;
|
|
96
|
+
|
|
97
|
+
await getFunnelRuns('funnel-456', {
|
|
98
|
+
status: 'completed',
|
|
99
|
+
page: 1,
|
|
100
|
+
page_size: 10,
|
|
101
|
+
ordering: '-started_at',
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(getFunnelRuns).toHaveBeenCalledWith('funnel-456', {
|
|
105
|
+
status: 'completed',
|
|
106
|
+
page: 1,
|
|
107
|
+
page_size: 10,
|
|
108
|
+
ordering: '-started_at',
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('should apply trigger type filter', async () => {
|
|
113
|
+
const getFunnelRuns = mockApiClient.getFunnelRuns as any;
|
|
114
|
+
|
|
115
|
+
await getFunnelRuns('funnel-456', {
|
|
116
|
+
trigger_type: 'scheduled',
|
|
117
|
+
page: 1,
|
|
118
|
+
page_size: 10,
|
|
119
|
+
ordering: '-started_at',
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
expect(getFunnelRuns).toHaveBeenCalledWith('funnel-456', {
|
|
123
|
+
trigger_type: 'scheduled',
|
|
124
|
+
page: 1,
|
|
125
|
+
page_size: 10,
|
|
126
|
+
ordering: '-started_at',
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe('handleReRun', () => {
|
|
132
|
+
it('should trigger new run with metadata', async () => {
|
|
133
|
+
const run = createMockRun({ id: 'run-123' });
|
|
134
|
+
const runFunnel = mockApiClient.runFunnel as any;
|
|
135
|
+
|
|
136
|
+
await runFunnel('funnel-456', {
|
|
137
|
+
trigger_type: 'manual',
|
|
138
|
+
metadata: { re_run_of: 'run-123' },
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
expect(runFunnel).toHaveBeenCalledWith('funnel-456', {
|
|
142
|
+
trigger_type: 'manual',
|
|
143
|
+
metadata: { re_run_of: 'run-123' },
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe('handleCancel', () => {
|
|
149
|
+
it('should cancel running job', async () => {
|
|
150
|
+
const run = createMockRun({ id: 'run-123', status: 'running' });
|
|
151
|
+
const cancelFunnelRun = mockApiClient.cancelFunnelRun as any;
|
|
152
|
+
|
|
153
|
+
await cancelFunnelRun('run-123');
|
|
154
|
+
|
|
155
|
+
expect(cancelFunnelRun).toHaveBeenCalledWith('run-123');
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe('pagination', () => {
|
|
160
|
+
it('should calculate total pages correctly', () => {
|
|
161
|
+
const total = 47;
|
|
162
|
+
const pageSize = 10;
|
|
163
|
+
const totalPages = Math.ceil(total / pageSize);
|
|
164
|
+
expect(totalPages).toBe(5);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('should calculate start and end indices', () => {
|
|
168
|
+
const page = 2;
|
|
169
|
+
const pageSize = 10;
|
|
170
|
+
const total = 47;
|
|
171
|
+
|
|
172
|
+
const startIndex = (page - 1) * pageSize + 1;
|
|
173
|
+
const endIndex = Math.min(page * pageSize, total);
|
|
174
|
+
|
|
175
|
+
expect(startIndex).toBe(11);
|
|
176
|
+
expect(endIndex).toBe(20);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe('auto-refresh', () => {
|
|
181
|
+
it('should detect active runs', () => {
|
|
182
|
+
const runs = [
|
|
183
|
+
createMockRun({ status: 'completed' }),
|
|
184
|
+
createMockRun({ status: 'running' }),
|
|
185
|
+
];
|
|
186
|
+
|
|
187
|
+
const hasActiveRuns = runs.some(
|
|
188
|
+
(r) => r.status === 'pending' || r.status === 'running'
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
expect(hasActiveRuns).toBe(true);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('should not refresh when no active runs', () => {
|
|
195
|
+
const runs = [
|
|
196
|
+
createMockRun({ status: 'completed' }),
|
|
197
|
+
createMockRun({ status: 'failed' }),
|
|
198
|
+
];
|
|
199
|
+
|
|
200
|
+
const hasActiveRuns = runs.some(
|
|
201
|
+
(r) => r.status === 'pending' || r.status === 'running'
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
expect(hasActiveRuns).toBe(false);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe('Run Status Scenarios', () => {
|
|
210
|
+
it('should handle completed run', () => {
|
|
211
|
+
const run = createMockRun({
|
|
212
|
+
status: 'completed',
|
|
213
|
+
total_input: 1000,
|
|
214
|
+
total_matched: 235,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
expect(run.status).toBe('completed');
|
|
218
|
+
expect(run.total_matched).toBe(235);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('should handle failed run', () => {
|
|
222
|
+
const run = createMockRun({
|
|
223
|
+
status: 'failed',
|
|
224
|
+
error: 'Database connection timeout',
|
|
225
|
+
total_matched: 0,
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
expect(run.status).toBe('failed');
|
|
229
|
+
expect(run.error).toBe('Database connection timeout');
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('should handle running run', () => {
|
|
233
|
+
const run = createMockRun({
|
|
234
|
+
status: 'running',
|
|
235
|
+
completed_at: undefined,
|
|
236
|
+
duration_ms: undefined,
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
expect(run.status).toBe('running');
|
|
240
|
+
expect(run.completed_at).toBeUndefined();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('should handle pending run', () => {
|
|
244
|
+
const run = createMockRun({
|
|
245
|
+
status: 'pending',
|
|
246
|
+
started_at: new Date().toISOString(),
|
|
247
|
+
completed_at: undefined,
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
expect(run.status).toBe('pending');
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('should handle cancelled run', () => {
|
|
254
|
+
const run = createMockRun({
|
|
255
|
+
status: 'cancelled',
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
expect(run.status).toBe('cancelled');
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
describe('Filter Scenarios', () => {
|
|
263
|
+
it('should filter by completed status', () => {
|
|
264
|
+
const runs = [
|
|
265
|
+
createMockRun({ id: '1', status: 'completed' }),
|
|
266
|
+
createMockRun({ id: '2', status: 'failed' }),
|
|
267
|
+
createMockRun({ id: '3', status: 'completed' }),
|
|
268
|
+
];
|
|
269
|
+
|
|
270
|
+
const filtered = runs.filter((r) => r.status === 'completed');
|
|
271
|
+
expect(filtered).toHaveLength(2);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('should filter by manual trigger', () => {
|
|
275
|
+
const runs = [
|
|
276
|
+
createMockRun({ id: '1', trigger_type: 'manual' }),
|
|
277
|
+
createMockRun({ id: '2', trigger_type: 'scheduled' }),
|
|
278
|
+
createMockRun({ id: '3', trigger_type: 'manual' }),
|
|
279
|
+
];
|
|
280
|
+
|
|
281
|
+
const filtered = runs.filter((r) => r.trigger_type === 'manual');
|
|
282
|
+
expect(filtered).toHaveLength(2);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
describe('Edge Cases', () => {
|
|
287
|
+
it('should handle empty runs', () => {
|
|
288
|
+
const runs: FunnelRun[] = [];
|
|
289
|
+
expect(runs).toHaveLength(0);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('should handle run with no stage stats', () => {
|
|
293
|
+
const run = createMockRun({
|
|
294
|
+
stage_stats: {},
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const stageStatsArray = Object.values(run.stage_stats);
|
|
298
|
+
expect(stageStatsArray).toHaveLength(0);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('should handle run with zero match rate', () => {
|
|
302
|
+
const run = createMockRun({
|
|
303
|
+
total_input: 1000,
|
|
304
|
+
total_matched: 0,
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
const matchRate = run.total_input > 0
|
|
308
|
+
? Math.round((run.total_matched / run.total_input) * 100)
|
|
309
|
+
: 0;
|
|
310
|
+
|
|
311
|
+
expect(matchRate).toBe(0);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('should handle run with 100% match rate', () => {
|
|
315
|
+
const run = createMockRun({
|
|
316
|
+
total_input: 1000,
|
|
317
|
+
total_matched: 1000,
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
const matchRate = Math.round((run.total_matched / run.total_input) * 100);
|
|
321
|
+
expect(matchRate).toBe(100);
|
|
322
|
+
});
|
|
323
|
+
});
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FunnelRunHistory Component
|
|
3
|
+
*
|
|
4
|
+
* Displays execution history for a funnel with filtering, sorting, and pagination.
|
|
5
|
+
*
|
|
6
|
+
* Design Goal:
|
|
7
|
+
* Users should be able to quickly scan run history, filter by status/trigger/date,
|
|
8
|
+
* view detailed breakdowns, and take actions (re-run, view results, cancel).
|
|
9
|
+
*
|
|
10
|
+
* Visual Hierarchy:
|
|
11
|
+
* - Header with title and refresh controls
|
|
12
|
+
* - Filter bar for narrowing results
|
|
13
|
+
* - Table showing run history (sorted by date by default)
|
|
14
|
+
* - Pagination controls
|
|
15
|
+
* - Details modal for deep dive
|
|
16
|
+
*
|
|
17
|
+
* Interaction Notes:
|
|
18
|
+
* - Auto-refresh when active runs exist (every 5 seconds)
|
|
19
|
+
* - Click row to view details
|
|
20
|
+
* - Action dropdown for quick actions
|
|
21
|
+
* - Filters apply immediately
|
|
22
|
+
* - Pagination loads new data
|
|
23
|
+
*
|
|
24
|
+
* Responsive Behavior:
|
|
25
|
+
* - Desktop: Full table with all columns
|
|
26
|
+
* - Tablet: Hide duration and trigger columns
|
|
27
|
+
* - Mobile: Card-based layout (responsive table variant)
|
|
28
|
+
*
|
|
29
|
+
* Accessibility:
|
|
30
|
+
* - Keyboard navigation throughout
|
|
31
|
+
* - ARIA labels for screen readers
|
|
32
|
+
* - Loading states announced
|
|
33
|
+
* - Error states clearly communicated
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
37
|
+
import { FunnelRun } from '../../types';
|
|
38
|
+
import { FunnelApiClient } from '../../api/client';
|
|
39
|
+
import { RunFilters } from './RunFilters';
|
|
40
|
+
import { RunRow } from './RunRow';
|
|
41
|
+
import { RunDetailsModal } from './RunDetailsModal';
|
|
42
|
+
import { RunFilters as RunFiltersType, Pagination } from './types';
|
|
43
|
+
|
|
44
|
+
interface FunnelRunHistoryProps {
|
|
45
|
+
funnelId: string;
|
|
46
|
+
apiClient: FunnelApiClient;
|
|
47
|
+
onViewResults?: (run: FunnelRun) => void;
|
|
48
|
+
className?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function FunnelRunHistory({
|
|
52
|
+
funnelId,
|
|
53
|
+
apiClient,
|
|
54
|
+
onViewResults,
|
|
55
|
+
className = '',
|
|
56
|
+
}: FunnelRunHistoryProps) {
|
|
57
|
+
// State
|
|
58
|
+
const [runs, setRuns] = useState<FunnelRun[]>([]);
|
|
59
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
60
|
+
const [error, setError] = useState<string | null>(null);
|
|
61
|
+
const [selectedRun, setSelectedRun] = useState<FunnelRun | null>(null);
|
|
62
|
+
const [filters, setFilters] = useState<RunFiltersType>({
|
|
63
|
+
status: 'all',
|
|
64
|
+
trigger_type: 'all',
|
|
65
|
+
date_range: 'month',
|
|
66
|
+
});
|
|
67
|
+
const [pagination, setPagination] = useState<Pagination>({
|
|
68
|
+
page: 1,
|
|
69
|
+
page_size: 10,
|
|
70
|
+
total: 0,
|
|
71
|
+
});
|
|
72
|
+
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
73
|
+
|
|
74
|
+
// Load runs
|
|
75
|
+
const loadRuns = useCallback(async () => {
|
|
76
|
+
try {
|
|
77
|
+
setError(null);
|
|
78
|
+
const params: any = {
|
|
79
|
+
page: pagination.page,
|
|
80
|
+
page_size: pagination.page_size,
|
|
81
|
+
ordering: '-started_at', // Most recent first
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// Add filters
|
|
85
|
+
if (filters.status && filters.status !== 'all') {
|
|
86
|
+
params.status = filters.status;
|
|
87
|
+
}
|
|
88
|
+
if (filters.trigger_type && filters.trigger_type !== 'all') {
|
|
89
|
+
params.trigger_type = filters.trigger_type;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const response = await apiClient.getFunnelRuns(funnelId, params);
|
|
93
|
+
|
|
94
|
+
setRuns(response.results);
|
|
95
|
+
setPagination((prev) => ({
|
|
96
|
+
...prev,
|
|
97
|
+
total: response.count,
|
|
98
|
+
}));
|
|
99
|
+
} catch (err) {
|
|
100
|
+
setError(err instanceof Error ? err.message : 'Failed to load runs');
|
|
101
|
+
console.error('Failed to load funnel runs:', err);
|
|
102
|
+
} finally {
|
|
103
|
+
setIsLoading(false);
|
|
104
|
+
setIsRefreshing(false);
|
|
105
|
+
}
|
|
106
|
+
}, [funnelId, apiClient, pagination.page, pagination.page_size, filters]);
|
|
107
|
+
|
|
108
|
+
// Load runs on mount and when dependencies change
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
loadRuns();
|
|
111
|
+
}, [loadRuns]);
|
|
112
|
+
|
|
113
|
+
// Auto-refresh for active runs
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
const hasActiveRuns = runs.some(
|
|
116
|
+
(r) => r.status === 'pending' || r.status === 'running'
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
if (hasActiveRuns) {
|
|
120
|
+
const interval = setInterval(() => {
|
|
121
|
+
setIsRefreshing(true);
|
|
122
|
+
loadRuns();
|
|
123
|
+
}, 5000); // Poll every 5 seconds
|
|
124
|
+
|
|
125
|
+
return () => clearInterval(interval);
|
|
126
|
+
}
|
|
127
|
+
}, [runs, loadRuns]);
|
|
128
|
+
|
|
129
|
+
// Manual refresh
|
|
130
|
+
const handleRefresh = () => {
|
|
131
|
+
setIsRefreshing(true);
|
|
132
|
+
loadRuns();
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// Actions
|
|
136
|
+
const handleReRun = async (run: FunnelRun) => {
|
|
137
|
+
try {
|
|
138
|
+
await apiClient.runFunnel(funnelId, {
|
|
139
|
+
trigger_type: 'manual',
|
|
140
|
+
metadata: { re_run_of: run.id },
|
|
141
|
+
});
|
|
142
|
+
// Refresh to show new run
|
|
143
|
+
loadRuns();
|
|
144
|
+
} catch (err) {
|
|
145
|
+
console.error('Failed to re-run funnel:', err);
|
|
146
|
+
alert('Failed to re-run funnel. Please try again.');
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const handleCancel = async (run: FunnelRun) => {
|
|
151
|
+
try {
|
|
152
|
+
await apiClient.cancelFunnelRun(run.id);
|
|
153
|
+
// Refresh to show updated status
|
|
154
|
+
loadRuns();
|
|
155
|
+
} catch (err) {
|
|
156
|
+
console.error('Failed to cancel run:', err);
|
|
157
|
+
alert('Failed to cancel run. Please try again.');
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const handleViewResults = (run: FunnelRun) => {
|
|
162
|
+
if (onViewResults) {
|
|
163
|
+
onViewResults(run);
|
|
164
|
+
} else {
|
|
165
|
+
// Default: open details modal
|
|
166
|
+
setSelectedRun(run);
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
// Pagination
|
|
171
|
+
const totalPages = Math.ceil(pagination.total / pagination.page_size);
|
|
172
|
+
const canGoBack = pagination.page > 1;
|
|
173
|
+
const canGoForward = pagination.page < totalPages;
|
|
174
|
+
|
|
175
|
+
const handlePreviousPage = () => {
|
|
176
|
+
if (canGoBack) {
|
|
177
|
+
setPagination((prev) => ({ ...prev, page: prev.page - 1 }));
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const handleNextPage = () => {
|
|
182
|
+
if (canGoForward) {
|
|
183
|
+
setPagination((prev) => ({ ...prev, page: prev.page + 1 }));
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const startIndex = (pagination.page - 1) * pagination.page_size + 1;
|
|
188
|
+
const endIndex = Math.min(
|
|
189
|
+
pagination.page * pagination.page_size,
|
|
190
|
+
pagination.total
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
return (
|
|
194
|
+
<div className={`bg-white rounded-lg border border-gray-200 ${className}`}>
|
|
195
|
+
{/* Header */}
|
|
196
|
+
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
|
197
|
+
<h2 className="text-lg font-semibold text-gray-900">
|
|
198
|
+
Funnel Run History
|
|
199
|
+
</h2>
|
|
200
|
+
<button
|
|
201
|
+
onClick={handleRefresh}
|
|
202
|
+
disabled={isRefreshing}
|
|
203
|
+
className="px-3 py-1.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
204
|
+
aria-label="Refresh run history"
|
|
205
|
+
>
|
|
206
|
+
<span className={isRefreshing ? 'animate-spin inline-block' : ''}>
|
|
207
|
+
↻
|
|
208
|
+
</span>{' '}
|
|
209
|
+
Refresh
|
|
210
|
+
</button>
|
|
211
|
+
</div>
|
|
212
|
+
|
|
213
|
+
{/* Filters */}
|
|
214
|
+
<RunFilters filters={filters} onFiltersChange={setFilters} />
|
|
215
|
+
|
|
216
|
+
{/* Table */}
|
|
217
|
+
<div className="overflow-x-auto">
|
|
218
|
+
<table className="w-full">
|
|
219
|
+
<thead className="bg-gray-50 border-b border-gray-200">
|
|
220
|
+
<tr>
|
|
221
|
+
<th className="px-4 py-3 text-left text-xs font-medium text-gray-600 uppercase tracking-wider">
|
|
222
|
+
Date
|
|
223
|
+
</th>
|
|
224
|
+
<th className="px-4 py-3 text-left text-xs font-medium text-gray-600 uppercase tracking-wider">
|
|
225
|
+
Status
|
|
226
|
+
</th>
|
|
227
|
+
<th className="px-4 py-3 text-left text-xs font-medium text-gray-600 uppercase tracking-wider">
|
|
228
|
+
Trigger
|
|
229
|
+
</th>
|
|
230
|
+
<th className="px-4 py-3 text-left text-xs font-medium text-gray-600 uppercase tracking-wider">
|
|
231
|
+
Duration
|
|
232
|
+
</th>
|
|
233
|
+
<th className="px-4 py-3 text-right text-xs font-medium text-gray-600 uppercase tracking-wider">
|
|
234
|
+
Input
|
|
235
|
+
</th>
|
|
236
|
+
<th className="px-4 py-3 text-right text-xs font-medium text-gray-600 uppercase tracking-wider">
|
|
237
|
+
Matched
|
|
238
|
+
</th>
|
|
239
|
+
<th className="px-4 py-3 text-right text-xs font-medium text-gray-600 uppercase tracking-wider">
|
|
240
|
+
%
|
|
241
|
+
</th>
|
|
242
|
+
<th className="px-4 py-3 text-right text-xs font-medium text-gray-600 uppercase tracking-wider">
|
|
243
|
+
<span className="sr-only">Actions</span>
|
|
244
|
+
</th>
|
|
245
|
+
</tr>
|
|
246
|
+
</thead>
|
|
247
|
+
<tbody>
|
|
248
|
+
{isLoading && runs.length === 0 ? (
|
|
249
|
+
<tr>
|
|
250
|
+
<td colSpan={8} className="px-4 py-12 text-center">
|
|
251
|
+
<div className="text-gray-500">Loading runs...</div>
|
|
252
|
+
</td>
|
|
253
|
+
</tr>
|
|
254
|
+
) : error ? (
|
|
255
|
+
<tr>
|
|
256
|
+
<td colSpan={8} className="px-4 py-12 text-center">
|
|
257
|
+
<div className="text-red-600">Error: {error}</div>
|
|
258
|
+
<button
|
|
259
|
+
onClick={loadRuns}
|
|
260
|
+
className="mt-2 text-sm text-blue-600 hover:text-blue-700 underline"
|
|
261
|
+
>
|
|
262
|
+
Try again
|
|
263
|
+
</button>
|
|
264
|
+
</td>
|
|
265
|
+
</tr>
|
|
266
|
+
) : runs.length === 0 ? (
|
|
267
|
+
<tr>
|
|
268
|
+
<td colSpan={8} className="px-4 py-12 text-center">
|
|
269
|
+
<div className="text-gray-500">
|
|
270
|
+
No runs found. Run this funnel to see history.
|
|
271
|
+
</div>
|
|
272
|
+
</td>
|
|
273
|
+
</tr>
|
|
274
|
+
) : (
|
|
275
|
+
runs.map((run) => (
|
|
276
|
+
<RunRow
|
|
277
|
+
key={run.id}
|
|
278
|
+
run={run}
|
|
279
|
+
onViewDetails={setSelectedRun}
|
|
280
|
+
onViewResults={handleViewResults}
|
|
281
|
+
onReRun={handleReRun}
|
|
282
|
+
onCancel={handleCancel}
|
|
283
|
+
/>
|
|
284
|
+
))
|
|
285
|
+
)}
|
|
286
|
+
</tbody>
|
|
287
|
+
</table>
|
|
288
|
+
</div>
|
|
289
|
+
|
|
290
|
+
{/* Pagination */}
|
|
291
|
+
{runs.length > 0 && (
|
|
292
|
+
<div className="px-6 py-3 border-t border-gray-200 flex items-center justify-between">
|
|
293
|
+
<div className="text-sm text-gray-600">
|
|
294
|
+
Showing {startIndex}-{endIndex} of {pagination.total}
|
|
295
|
+
</div>
|
|
296
|
+
<div className="flex items-center gap-2">
|
|
297
|
+
<button
|
|
298
|
+
onClick={handlePreviousPage}
|
|
299
|
+
disabled={!canGoBack}
|
|
300
|
+
className="px-3 py-1 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
301
|
+
aria-label="Previous page"
|
|
302
|
+
>
|
|
303
|
+
‹
|
|
304
|
+
</button>
|
|
305
|
+
<span className="text-sm text-gray-600">
|
|
306
|
+
Page {pagination.page} of {totalPages}
|
|
307
|
+
</span>
|
|
308
|
+
<button
|
|
309
|
+
onClick={handleNextPage}
|
|
310
|
+
disabled={!canGoForward}
|
|
311
|
+
className="px-3 py-1 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
312
|
+
aria-label="Next page"
|
|
313
|
+
>
|
|
314
|
+
›
|
|
315
|
+
</button>
|
|
316
|
+
</div>
|
|
317
|
+
</div>
|
|
318
|
+
)}
|
|
319
|
+
|
|
320
|
+
{/* Details Modal */}
|
|
321
|
+
<RunDetailsModal
|
|
322
|
+
run={selectedRun}
|
|
323
|
+
onClose={() => setSelectedRun(null)}
|
|
324
|
+
onViewResults={onViewResults}
|
|
325
|
+
onReRun={handleReRun}
|
|
326
|
+
/>
|
|
327
|
+
</div>
|
|
328
|
+
);
|
|
329
|
+
}
|