@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,686 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for createFunnelStore
|
|
3
|
+
*
|
|
4
|
+
* Tests:
|
|
5
|
+
* - CRUD operations
|
|
6
|
+
* - Optimistic updates
|
|
7
|
+
* - Error handling with rollback
|
|
8
|
+
* - Dirty tracking
|
|
9
|
+
* - Selection state
|
|
10
|
+
* - Stage management
|
|
11
|
+
* - Run operations
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
15
|
+
import { createFunnelStore } from './create-funnel-store';
|
|
16
|
+
import type { FunnelApiClient, PaginatedResponse } from '../api/client';
|
|
17
|
+
import type {
|
|
18
|
+
Funnel,
|
|
19
|
+
FunnelStage,
|
|
20
|
+
FunnelRun,
|
|
21
|
+
CreateFunnelInput,
|
|
22
|
+
} from '../types';
|
|
23
|
+
|
|
24
|
+
// ============================================================================
|
|
25
|
+
// Mock API Client
|
|
26
|
+
// ============================================================================
|
|
27
|
+
|
|
28
|
+
const createMockApiClient = (): FunnelApiClient => {
|
|
29
|
+
return {
|
|
30
|
+
listFunnels: vi.fn(),
|
|
31
|
+
getFunnel: vi.fn(),
|
|
32
|
+
createFunnel: vi.fn(),
|
|
33
|
+
updateFunnel: vi.fn(),
|
|
34
|
+
deleteFunnel: vi.fn(),
|
|
35
|
+
createStage: vi.fn(),
|
|
36
|
+
updateStage: vi.fn(),
|
|
37
|
+
deleteStage: vi.fn(),
|
|
38
|
+
runFunnel: vi.fn(),
|
|
39
|
+
getFunnelRuns: vi.fn(),
|
|
40
|
+
getFunnelRun: vi.fn(),
|
|
41
|
+
getFunnelResults: vi.fn(),
|
|
42
|
+
cancelFunnelRun: vi.fn(),
|
|
43
|
+
previewFunnel: vi.fn(),
|
|
44
|
+
previewFunnelServer: vi.fn(),
|
|
45
|
+
} as unknown as FunnelApiClient;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// ============================================================================
|
|
49
|
+
// Test Data
|
|
50
|
+
// ============================================================================
|
|
51
|
+
|
|
52
|
+
const mockFunnel: Funnel = {
|
|
53
|
+
id: 'funnel-1',
|
|
54
|
+
name: 'Test Funnel',
|
|
55
|
+
description: 'Test description',
|
|
56
|
+
status: 'active',
|
|
57
|
+
input_type: 'contacts',
|
|
58
|
+
stages: [
|
|
59
|
+
{
|
|
60
|
+
id: 'stage-1',
|
|
61
|
+
order: 0,
|
|
62
|
+
name: 'Stage 1',
|
|
63
|
+
filter_logic: 'AND',
|
|
64
|
+
rules: [],
|
|
65
|
+
match_action: 'continue',
|
|
66
|
+
no_match_action: 'exclude',
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: 'stage-2',
|
|
70
|
+
order: 1,
|
|
71
|
+
name: 'Stage 2',
|
|
72
|
+
filter_logic: 'OR',
|
|
73
|
+
rules: [],
|
|
74
|
+
match_action: 'output',
|
|
75
|
+
no_match_action: 'exclude',
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
created_at: new Date().toISOString(),
|
|
79
|
+
updated_at: new Date().toISOString(),
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const mockFunnel2: Funnel = {
|
|
83
|
+
id: 'funnel-2',
|
|
84
|
+
name: 'Test Funnel 2',
|
|
85
|
+
status: 'draft',
|
|
86
|
+
input_type: 'organizations',
|
|
87
|
+
stages: [],
|
|
88
|
+
created_at: new Date().toISOString(),
|
|
89
|
+
updated_at: new Date().toISOString(),
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const mockRun: FunnelRun = {
|
|
93
|
+
id: 'run-1',
|
|
94
|
+
funnel_id: 'funnel-1',
|
|
95
|
+
status: 'completed',
|
|
96
|
+
trigger_type: 'manual',
|
|
97
|
+
started_at: new Date().toISOString(),
|
|
98
|
+
completed_at: new Date().toISOString(),
|
|
99
|
+
total_input: 100,
|
|
100
|
+
total_matched: 75,
|
|
101
|
+
total_excluded: 25,
|
|
102
|
+
total_tagged: 50,
|
|
103
|
+
stage_stats: {},
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// ============================================================================
|
|
107
|
+
// Tests
|
|
108
|
+
// ============================================================================
|
|
109
|
+
|
|
110
|
+
describe('createFunnelStore', () => {
|
|
111
|
+
let apiClient: FunnelApiClient;
|
|
112
|
+
let useFunnelStore: ReturnType<typeof createFunnelStore>;
|
|
113
|
+
|
|
114
|
+
beforeEach(() => {
|
|
115
|
+
apiClient = createMockApiClient();
|
|
116
|
+
useFunnelStore = createFunnelStore(apiClient);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('Initial State', () => {
|
|
120
|
+
it('should initialize with empty state', () => {
|
|
121
|
+
const state = useFunnelStore.getState();
|
|
122
|
+
|
|
123
|
+
expect(state.funnels).toEqual([]);
|
|
124
|
+
expect(state.selectedFunnel).toBeNull();
|
|
125
|
+
expect(state.selectedStage).toBeNull();
|
|
126
|
+
expect(state.runs).toEqual([]);
|
|
127
|
+
expect(state.isLoading).toBe(false);
|
|
128
|
+
expect(state.error).toBeNull();
|
|
129
|
+
expect(state.isDirty).toBe(false);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe('loadFunnels', () => {
|
|
134
|
+
it('should load funnels successfully', async () => {
|
|
135
|
+
const mockResponse: PaginatedResponse<Funnel> = {
|
|
136
|
+
count: 2,
|
|
137
|
+
next: null,
|
|
138
|
+
previous: null,
|
|
139
|
+
results: [mockFunnel, mockFunnel2],
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
vi.mocked(apiClient.listFunnels).mockResolvedValue(mockResponse);
|
|
143
|
+
|
|
144
|
+
const { loadFunnels } = useFunnelStore.getState();
|
|
145
|
+
await loadFunnels();
|
|
146
|
+
|
|
147
|
+
const state = useFunnelStore.getState();
|
|
148
|
+
expect(state.funnels).toHaveLength(2);
|
|
149
|
+
expect(state.funnels[0].id).toBe('funnel-1');
|
|
150
|
+
expect(state.pagination.count).toBe(2);
|
|
151
|
+
expect(state.isLoading).toBe(false);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should handle loading error', async () => {
|
|
155
|
+
const error = new Error('Network error');
|
|
156
|
+
vi.mocked(apiClient.listFunnels).mockRejectedValue(error);
|
|
157
|
+
|
|
158
|
+
const { loadFunnels } = useFunnelStore.getState();
|
|
159
|
+
|
|
160
|
+
await expect(loadFunnels()).rejects.toThrow('Network error');
|
|
161
|
+
|
|
162
|
+
const state = useFunnelStore.getState();
|
|
163
|
+
expect(state.error).toBe(error);
|
|
164
|
+
expect(state.isLoading).toBe(false);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('should apply filters and pagination', async () => {
|
|
168
|
+
const mockResponse: PaginatedResponse<Funnel> = {
|
|
169
|
+
count: 1,
|
|
170
|
+
next: null,
|
|
171
|
+
previous: null,
|
|
172
|
+
results: [mockFunnel],
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
vi.mocked(apiClient.listFunnels).mockResolvedValue(mockResponse);
|
|
176
|
+
|
|
177
|
+
const { loadFunnels } = useFunnelStore.getState();
|
|
178
|
+
await loadFunnels({ status: 'active', page: 2, page_size: 10 });
|
|
179
|
+
|
|
180
|
+
expect(apiClient.listFunnels).toHaveBeenCalledWith({
|
|
181
|
+
status: 'active',
|
|
182
|
+
page: 2,
|
|
183
|
+
page_size: 10,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const state = useFunnelStore.getState();
|
|
187
|
+
expect(state.pagination.currentPage).toBe(2);
|
|
188
|
+
expect(state.pagination.pageSize).toBe(10);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe('selectFunnel', () => {
|
|
193
|
+
it('should select funnel by id', async () => {
|
|
194
|
+
const mockResponse: PaginatedResponse<Funnel> = {
|
|
195
|
+
count: 1,
|
|
196
|
+
next: null,
|
|
197
|
+
previous: null,
|
|
198
|
+
results: [mockFunnel],
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
vi.mocked(apiClient.listFunnels).mockResolvedValue(mockResponse);
|
|
202
|
+
|
|
203
|
+
const { loadFunnels, selectFunnel } = useFunnelStore.getState();
|
|
204
|
+
await loadFunnels();
|
|
205
|
+
selectFunnel('funnel-1');
|
|
206
|
+
|
|
207
|
+
const state = useFunnelStore.getState();
|
|
208
|
+
expect(state.selectedFunnel?.id).toBe('funnel-1');
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('should clear selection when id is null', () => {
|
|
212
|
+
const { selectFunnel } = useFunnelStore.getState();
|
|
213
|
+
selectFunnel(null);
|
|
214
|
+
|
|
215
|
+
const state = useFunnelStore.getState();
|
|
216
|
+
expect(state.selectedFunnel).toBeNull();
|
|
217
|
+
expect(state.selectedStage).toBeNull();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('should clear stage selection when changing funnels', async () => {
|
|
221
|
+
const mockResponse: PaginatedResponse<Funnel> = {
|
|
222
|
+
count: 2,
|
|
223
|
+
next: null,
|
|
224
|
+
previous: null,
|
|
225
|
+
results: [mockFunnel, mockFunnel2],
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
vi.mocked(apiClient.listFunnels).mockResolvedValue(mockResponse);
|
|
229
|
+
|
|
230
|
+
const { loadFunnels, selectFunnel, selectStage } = useFunnelStore.getState();
|
|
231
|
+
await loadFunnels();
|
|
232
|
+
|
|
233
|
+
selectFunnel('funnel-1');
|
|
234
|
+
selectStage('stage-1');
|
|
235
|
+
|
|
236
|
+
expect(useFunnelStore.getState().selectedStage?.id).toBe('stage-1');
|
|
237
|
+
|
|
238
|
+
selectFunnel('funnel-2');
|
|
239
|
+
|
|
240
|
+
expect(useFunnelStore.getState().selectedStage).toBeNull();
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe('createFunnel', () => {
|
|
245
|
+
it('should create funnel successfully', async () => {
|
|
246
|
+
vi.mocked(apiClient.createFunnel).mockResolvedValue(mockFunnel);
|
|
247
|
+
|
|
248
|
+
const { createFunnel } = useFunnelStore.getState();
|
|
249
|
+
const input: CreateFunnelInput = {
|
|
250
|
+
name: 'Test Funnel',
|
|
251
|
+
status: 'active',
|
|
252
|
+
input_type: 'contacts',
|
|
253
|
+
stages: [],
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const result = await createFunnel(input);
|
|
257
|
+
|
|
258
|
+
expect(result).toEqual(mockFunnel);
|
|
259
|
+
expect(useFunnelStore.getState().funnels).toHaveLength(1);
|
|
260
|
+
expect(useFunnelStore.getState().pagination.count).toBe(1);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('should handle creation error', async () => {
|
|
264
|
+
const error = new Error('Validation error');
|
|
265
|
+
vi.mocked(apiClient.createFunnel).mockRejectedValue(error);
|
|
266
|
+
|
|
267
|
+
const { createFunnel } = useFunnelStore.getState();
|
|
268
|
+
const input: CreateFunnelInput = {
|
|
269
|
+
name: '',
|
|
270
|
+
status: 'active',
|
|
271
|
+
input_type: 'contacts',
|
|
272
|
+
stages: [],
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
await expect(createFunnel(input)).rejects.toThrow('Validation error');
|
|
276
|
+
|
|
277
|
+
const state = useFunnelStore.getState();
|
|
278
|
+
expect(state.error).toBe(error);
|
|
279
|
+
expect(state.funnels).toHaveLength(0);
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
describe('updateFunnel - Optimistic Updates', () => {
|
|
284
|
+
it('should update funnel optimistically', async () => {
|
|
285
|
+
const mockResponse: PaginatedResponse<Funnel> = {
|
|
286
|
+
count: 1,
|
|
287
|
+
next: null,
|
|
288
|
+
previous: null,
|
|
289
|
+
results: [mockFunnel],
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
vi.mocked(apiClient.listFunnels).mockResolvedValue(mockResponse);
|
|
293
|
+
|
|
294
|
+
const updatedFunnel = { ...mockFunnel, name: 'Updated Name' };
|
|
295
|
+
vi.mocked(apiClient.updateFunnel).mockResolvedValue(updatedFunnel);
|
|
296
|
+
|
|
297
|
+
const { loadFunnels, updateFunnel } = useFunnelStore.getState();
|
|
298
|
+
await loadFunnels();
|
|
299
|
+
|
|
300
|
+
// Update immediately reflects in store
|
|
301
|
+
const updatePromise = updateFunnel('funnel-1', { name: 'Updated Name' });
|
|
302
|
+
|
|
303
|
+
// Check optimistic update
|
|
304
|
+
let state = useFunnelStore.getState();
|
|
305
|
+
expect(state.funnels[0].name).toBe('Updated Name');
|
|
306
|
+
|
|
307
|
+
await updatePromise;
|
|
308
|
+
|
|
309
|
+
// Check final state
|
|
310
|
+
state = useFunnelStore.getState();
|
|
311
|
+
expect(state.funnels[0].name).toBe('Updated Name');
|
|
312
|
+
expect(state.isDirty).toBe(false);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('should rollback on update error', async () => {
|
|
316
|
+
const mockResponse: PaginatedResponse<Funnel> = {
|
|
317
|
+
count: 1,
|
|
318
|
+
next: null,
|
|
319
|
+
previous: null,
|
|
320
|
+
results: [mockFunnel],
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
vi.mocked(apiClient.listFunnels).mockResolvedValue(mockResponse);
|
|
324
|
+
|
|
325
|
+
const error = new Error('Update failed');
|
|
326
|
+
vi.mocked(apiClient.updateFunnel).mockRejectedValue(error);
|
|
327
|
+
|
|
328
|
+
const { loadFunnels, updateFunnel } = useFunnelStore.getState();
|
|
329
|
+
await loadFunnels();
|
|
330
|
+
|
|
331
|
+
const originalName = mockFunnel.name;
|
|
332
|
+
|
|
333
|
+
await expect(updateFunnel('funnel-1', { name: 'New Name' })).rejects.toThrow(
|
|
334
|
+
'Update failed'
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
// Should rollback to original
|
|
338
|
+
const state = useFunnelStore.getState();
|
|
339
|
+
expect(state.funnels[0].name).toBe(originalName);
|
|
340
|
+
expect(state.error?.message).toBe('Update failed');
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('should update selectedFunnel when updated', async () => {
|
|
344
|
+
const mockResponse: PaginatedResponse<Funnel> = {
|
|
345
|
+
count: 1,
|
|
346
|
+
next: null,
|
|
347
|
+
previous: null,
|
|
348
|
+
results: [mockFunnel],
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
vi.mocked(apiClient.listFunnels).mockResolvedValue(mockResponse);
|
|
352
|
+
|
|
353
|
+
const updatedFunnel = { ...mockFunnel, name: 'Updated Name' };
|
|
354
|
+
vi.mocked(apiClient.updateFunnel).mockResolvedValue(updatedFunnel);
|
|
355
|
+
|
|
356
|
+
const { loadFunnels, selectFunnel, updateFunnel } = useFunnelStore.getState();
|
|
357
|
+
await loadFunnels();
|
|
358
|
+
selectFunnel('funnel-1');
|
|
359
|
+
|
|
360
|
+
await updateFunnel('funnel-1', { name: 'Updated Name' });
|
|
361
|
+
|
|
362
|
+
const state = useFunnelStore.getState();
|
|
363
|
+
expect(state.selectedFunnel?.name).toBe('Updated Name');
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
describe('deleteFunnel - Optimistic Updates', () => {
|
|
368
|
+
it('should delete funnel optimistically', async () => {
|
|
369
|
+
const mockResponse: PaginatedResponse<Funnel> = {
|
|
370
|
+
count: 2,
|
|
371
|
+
next: null,
|
|
372
|
+
previous: null,
|
|
373
|
+
results: [mockFunnel, mockFunnel2],
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
vi.mocked(apiClient.listFunnels).mockResolvedValue(mockResponse);
|
|
377
|
+
vi.mocked(apiClient.deleteFunnel).mockResolvedValue();
|
|
378
|
+
|
|
379
|
+
const { loadFunnels, deleteFunnel } = useFunnelStore.getState();
|
|
380
|
+
await loadFunnels();
|
|
381
|
+
|
|
382
|
+
await deleteFunnel('funnel-1');
|
|
383
|
+
|
|
384
|
+
const state = useFunnelStore.getState();
|
|
385
|
+
expect(state.funnels).toHaveLength(1);
|
|
386
|
+
expect(state.funnels[0].id).toBe('funnel-2');
|
|
387
|
+
expect(state.pagination.count).toBe(1);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it('should rollback on delete error', async () => {
|
|
391
|
+
const mockResponse: PaginatedResponse<Funnel> = {
|
|
392
|
+
count: 1,
|
|
393
|
+
next: null,
|
|
394
|
+
previous: null,
|
|
395
|
+
results: [mockFunnel],
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
vi.mocked(apiClient.listFunnels).mockResolvedValue(mockResponse);
|
|
399
|
+
|
|
400
|
+
const error = new Error('Delete failed');
|
|
401
|
+
vi.mocked(apiClient.deleteFunnel).mockRejectedValue(error);
|
|
402
|
+
|
|
403
|
+
const { loadFunnels, deleteFunnel } = useFunnelStore.getState();
|
|
404
|
+
await loadFunnels();
|
|
405
|
+
|
|
406
|
+
await expect(deleteFunnel('funnel-1')).rejects.toThrow('Delete failed');
|
|
407
|
+
|
|
408
|
+
const state = useFunnelStore.getState();
|
|
409
|
+
expect(state.funnels).toHaveLength(1);
|
|
410
|
+
expect(state.funnels[0].id).toBe('funnel-1');
|
|
411
|
+
expect(state.error?.message).toBe('Delete failed');
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it('should clear selectedFunnel when deleting selected funnel', async () => {
|
|
415
|
+
const mockResponse: PaginatedResponse<Funnel> = {
|
|
416
|
+
count: 1,
|
|
417
|
+
next: null,
|
|
418
|
+
previous: null,
|
|
419
|
+
results: [mockFunnel],
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
vi.mocked(apiClient.listFunnels).mockResolvedValue(mockResponse);
|
|
423
|
+
vi.mocked(apiClient.deleteFunnel).mockResolvedValue();
|
|
424
|
+
|
|
425
|
+
const { loadFunnels, selectFunnel, deleteFunnel } = useFunnelStore.getState();
|
|
426
|
+
await loadFunnels();
|
|
427
|
+
selectFunnel('funnel-1');
|
|
428
|
+
|
|
429
|
+
await deleteFunnel('funnel-1');
|
|
430
|
+
|
|
431
|
+
const state = useFunnelStore.getState();
|
|
432
|
+
expect(state.selectedFunnel).toBeNull();
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
describe('duplicateFunnel', () => {
|
|
437
|
+
it('should duplicate funnel with new name', async () => {
|
|
438
|
+
const mockResponse: PaginatedResponse<Funnel> = {
|
|
439
|
+
count: 1,
|
|
440
|
+
next: null,
|
|
441
|
+
previous: null,
|
|
442
|
+
results: [mockFunnel],
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
vi.mocked(apiClient.listFunnels).mockResolvedValue(mockResponse);
|
|
446
|
+
|
|
447
|
+
const duplicated = {
|
|
448
|
+
...mockFunnel,
|
|
449
|
+
id: 'funnel-3',
|
|
450
|
+
name: 'Test Funnel (Copy)',
|
|
451
|
+
status: 'draft' as const,
|
|
452
|
+
};
|
|
453
|
+
vi.mocked(apiClient.createFunnel).mockResolvedValue(duplicated);
|
|
454
|
+
|
|
455
|
+
const { loadFunnels, duplicateFunnel } = useFunnelStore.getState();
|
|
456
|
+
await loadFunnels();
|
|
457
|
+
|
|
458
|
+
const result = await duplicateFunnel('funnel-1');
|
|
459
|
+
|
|
460
|
+
expect(result.name).toBe('Test Funnel (Copy)');
|
|
461
|
+
expect(result.status).toBe('draft');
|
|
462
|
+
expect(apiClient.createFunnel).toHaveBeenCalledWith(
|
|
463
|
+
expect.objectContaining({
|
|
464
|
+
name: 'Test Funnel (Copy)',
|
|
465
|
+
status: 'draft',
|
|
466
|
+
})
|
|
467
|
+
);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it('should throw if funnel not found', async () => {
|
|
471
|
+
const { duplicateFunnel } = useFunnelStore.getState();
|
|
472
|
+
|
|
473
|
+
await expect(duplicateFunnel('nonexistent')).rejects.toThrow(
|
|
474
|
+
'Funnel nonexistent not found'
|
|
475
|
+
);
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
describe('Stage Management', () => {
|
|
480
|
+
beforeEach(async () => {
|
|
481
|
+
const mockResponse: PaginatedResponse<Funnel> = {
|
|
482
|
+
count: 1,
|
|
483
|
+
next: null,
|
|
484
|
+
previous: null,
|
|
485
|
+
results: [mockFunnel],
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
vi.mocked(apiClient.listFunnels).mockResolvedValue(mockResponse);
|
|
489
|
+
|
|
490
|
+
const { loadFunnels, selectFunnel } = useFunnelStore.getState();
|
|
491
|
+
await loadFunnels();
|
|
492
|
+
selectFunnel('funnel-1');
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it('should select stage', () => {
|
|
496
|
+
const { selectStage } = useFunnelStore.getState();
|
|
497
|
+
selectStage('stage-1');
|
|
498
|
+
|
|
499
|
+
const state = useFunnelStore.getState();
|
|
500
|
+
expect(state.selectedStage?.id).toBe('stage-1');
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it('should create stage', async () => {
|
|
504
|
+
const newStage: FunnelStage = {
|
|
505
|
+
id: 'stage-3',
|
|
506
|
+
order: 2,
|
|
507
|
+
name: 'Stage 3',
|
|
508
|
+
filter_logic: 'AND',
|
|
509
|
+
rules: [],
|
|
510
|
+
match_action: 'continue',
|
|
511
|
+
no_match_action: 'exclude',
|
|
512
|
+
};
|
|
513
|
+
|
|
514
|
+
vi.mocked(apiClient.createStage).mockResolvedValue(newStage);
|
|
515
|
+
|
|
516
|
+
const { createStage } = useFunnelStore.getState();
|
|
517
|
+
await createStage('funnel-1', {
|
|
518
|
+
order: 2,
|
|
519
|
+
name: 'Stage 3',
|
|
520
|
+
filter_logic: 'AND',
|
|
521
|
+
rules: [],
|
|
522
|
+
match_action: 'continue',
|
|
523
|
+
no_match_action: 'exclude',
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
const state = useFunnelStore.getState();
|
|
527
|
+
expect(state.selectedFunnel?.stages).toHaveLength(3);
|
|
528
|
+
expect(state.selectedFunnel?.stages[2].id).toBe('stage-3');
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it('should update stage optimistically', async () => {
|
|
532
|
+
const updatedStage = { ...mockFunnel.stages[0], name: 'Updated Stage' };
|
|
533
|
+
vi.mocked(apiClient.updateStage).mockResolvedValue(updatedStage);
|
|
534
|
+
|
|
535
|
+
const { updateStage } = useFunnelStore.getState();
|
|
536
|
+
await updateStage('funnel-1', 'stage-1', { name: 'Updated Stage' });
|
|
537
|
+
|
|
538
|
+
const state = useFunnelStore.getState();
|
|
539
|
+
expect(state.selectedFunnel?.stages[0].name).toBe('Updated Stage');
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it('should delete stage optimistically', async () => {
|
|
543
|
+
vi.mocked(apiClient.deleteStage).mockResolvedValue();
|
|
544
|
+
|
|
545
|
+
const { deleteStage } = useFunnelStore.getState();
|
|
546
|
+
await deleteStage('funnel-1', 'stage-1');
|
|
547
|
+
|
|
548
|
+
const state = useFunnelStore.getState();
|
|
549
|
+
expect(state.selectedFunnel?.stages).toHaveLength(1);
|
|
550
|
+
expect(state.selectedFunnel?.stages[0].id).toBe('stage-2');
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
it('should reorder stages', async () => {
|
|
554
|
+
const updatedStage1 = { ...mockFunnel.stages[0], order: 1 };
|
|
555
|
+
const updatedStage2 = { ...mockFunnel.stages[1], order: 0 };
|
|
556
|
+
|
|
557
|
+
vi.mocked(apiClient.updateStage)
|
|
558
|
+
.mockResolvedValueOnce(updatedStage2)
|
|
559
|
+
.mockResolvedValueOnce(updatedStage1);
|
|
560
|
+
|
|
561
|
+
const { reorderStages } = useFunnelStore.getState();
|
|
562
|
+
await reorderStages('funnel-1', ['stage-2', 'stage-1']);
|
|
563
|
+
|
|
564
|
+
const state = useFunnelStore.getState();
|
|
565
|
+
expect(state.selectedFunnel?.stages[0].id).toBe('stage-2');
|
|
566
|
+
expect(state.selectedFunnel?.stages[0].order).toBe(0);
|
|
567
|
+
expect(state.selectedFunnel?.stages[1].id).toBe('stage-1');
|
|
568
|
+
expect(state.selectedFunnel?.stages[1].order).toBe(1);
|
|
569
|
+
});
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
describe('Run Operations', () => {
|
|
573
|
+
it('should run funnel', async () => {
|
|
574
|
+
vi.mocked(apiClient.runFunnel).mockResolvedValue(mockRun);
|
|
575
|
+
|
|
576
|
+
const { runFunnel } = useFunnelStore.getState();
|
|
577
|
+
const result = await runFunnel('funnel-1', { trigger_type: 'manual' });
|
|
578
|
+
|
|
579
|
+
expect(result).toEqual(mockRun);
|
|
580
|
+
expect(useFunnelStore.getState().runs).toHaveLength(1);
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
it('should load runs', async () => {
|
|
584
|
+
const mockResponse: PaginatedResponse<FunnelRun> = {
|
|
585
|
+
count: 1,
|
|
586
|
+
next: null,
|
|
587
|
+
previous: null,
|
|
588
|
+
results: [mockRun],
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
vi.mocked(apiClient.getFunnelRuns).mockResolvedValue(mockResponse);
|
|
592
|
+
|
|
593
|
+
const { loadRuns } = useFunnelStore.getState();
|
|
594
|
+
await loadRuns('funnel-1');
|
|
595
|
+
|
|
596
|
+
const state = useFunnelStore.getState();
|
|
597
|
+
expect(state.runs).toHaveLength(1);
|
|
598
|
+
expect(state.runs[0].id).toBe('run-1');
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
it('should cancel run', async () => {
|
|
602
|
+
const cancelledRun = { ...mockRun, status: 'cancelled' as const };
|
|
603
|
+
vi.mocked(apiClient.cancelFunnelRun).mockResolvedValue(cancelledRun);
|
|
604
|
+
|
|
605
|
+
const mockResponse: PaginatedResponse<FunnelRun> = {
|
|
606
|
+
count: 1,
|
|
607
|
+
next: null,
|
|
608
|
+
previous: null,
|
|
609
|
+
results: [mockRun],
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
vi.mocked(apiClient.getFunnelRuns).mockResolvedValue(mockResponse);
|
|
613
|
+
|
|
614
|
+
const { loadRuns, cancelRun } = useFunnelStore.getState();
|
|
615
|
+
await loadRuns('funnel-1');
|
|
616
|
+
|
|
617
|
+
await cancelRun('run-1');
|
|
618
|
+
|
|
619
|
+
const state = useFunnelStore.getState();
|
|
620
|
+
expect(state.runs[0].status).toBe('cancelled');
|
|
621
|
+
});
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
describe('UI State Management', () => {
|
|
625
|
+
it('should set dirty flag', () => {
|
|
626
|
+
const { setDirty } = useFunnelStore.getState();
|
|
627
|
+
setDirty(true);
|
|
628
|
+
|
|
629
|
+
expect(useFunnelStore.getState().isDirty).toBe(true);
|
|
630
|
+
|
|
631
|
+
setDirty(false);
|
|
632
|
+
|
|
633
|
+
expect(useFunnelStore.getState().isDirty).toBe(false);
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
it('should clear error', () => {
|
|
637
|
+
const { clearError } = useFunnelStore.getState();
|
|
638
|
+
|
|
639
|
+
useFunnelStore.setState({ error: new Error('Test error') });
|
|
640
|
+
expect(useFunnelStore.getState().error).not.toBeNull();
|
|
641
|
+
|
|
642
|
+
clearError();
|
|
643
|
+
|
|
644
|
+
expect(useFunnelStore.getState().error).toBeNull();
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
it('should reset store', () => {
|
|
648
|
+
const mockResponse: PaginatedResponse<Funnel> = {
|
|
649
|
+
count: 1,
|
|
650
|
+
next: null,
|
|
651
|
+
previous: null,
|
|
652
|
+
results: [mockFunnel],
|
|
653
|
+
};
|
|
654
|
+
|
|
655
|
+
vi.mocked(apiClient.listFunnels).mockResolvedValue(mockResponse);
|
|
656
|
+
|
|
657
|
+
const { loadFunnels, reset } = useFunnelStore.getState();
|
|
658
|
+
loadFunnels();
|
|
659
|
+
|
|
660
|
+
reset();
|
|
661
|
+
|
|
662
|
+
const state = useFunnelStore.getState();
|
|
663
|
+
expect(state.funnels).toEqual([]);
|
|
664
|
+
expect(state.selectedFunnel).toBeNull();
|
|
665
|
+
expect(state.runs).toEqual([]);
|
|
666
|
+
expect(state.error).toBeNull();
|
|
667
|
+
});
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
describe('Factory Pattern', () => {
|
|
671
|
+
it('should create isolated store instances', () => {
|
|
672
|
+
const apiClient1 = createMockApiClient();
|
|
673
|
+
const apiClient2 = createMockApiClient();
|
|
674
|
+
|
|
675
|
+
const store1 = createFunnelStore(apiClient1);
|
|
676
|
+
const store2 = createFunnelStore(apiClient2);
|
|
677
|
+
|
|
678
|
+
// Modify store1
|
|
679
|
+
store1.setState({ isDirty: true });
|
|
680
|
+
|
|
681
|
+
// store2 should be independent
|
|
682
|
+
expect(store1.getState().isDirty).toBe(true);
|
|
683
|
+
expect(store2.getState().isDirty).toBe(false);
|
|
684
|
+
});
|
|
685
|
+
});
|
|
686
|
+
});
|