@startsimpli/funnels 0.4.11 → 0.4.14
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/README.md +326 -281
- package/package.json +17 -17
- package/src/api/client.paths.test.ts +162 -0
- package/src/api/client.ts +72 -19
- package/src/api/index.ts +11 -0
- package/src/api/paths.test.ts +125 -0
- package/src/api/paths.ts +146 -0
- package/src/components/FilterRuleEditor/FieldSelector.tsx +7 -5
- package/src/components/FilterRuleEditor/FilterRuleEditor.stories.tsx +3 -3
- package/src/components/FilterRuleEditor/FilterRuleEditor.test.tsx +13 -8
- package/src/components/FilterRuleEditor/FilterRuleEditor.tsx +7 -2
- package/src/components/FilterRuleEditor/OperatorSelector.tsx +1 -1
- package/src/components/FilterRuleEditor/RuleRow.test.tsx +166 -0
- package/src/components/FilterRuleEditor/RuleRow.tsx +73 -10
- package/src/components/FilterRuleEditor/constants.ts +12 -0
- package/src/components/FunnelPreview/example.tsx +15 -15
- package/src/components/FunnelStageBuilder/FunnelStageBuilder.stories.tsx +2 -2
- package/src/components/FunnelStageBuilder/FunnelStageBuilder.test.tsx +2 -2
- package/src/components/FunnelStageBuilder/FunnelStageBuilder.tsx +2 -2
- package/src/components/FunnelStageBuilder/StageCard.tsx +2 -2
- package/src/components/FunnelStageBuilder/StageForm.tsx +2 -2
- package/src/core/evaluator.example.ts +23 -23
- package/src/core/evaluator.test.ts +4 -1
- package/src/core/evaluator.ts +11 -5
- package/src/core/operators.ts +35 -0
- package/src/store/create-funnel-store.ts +1 -0
- package/src/stories/demo-data/investors.ts +2 -2
- package/src/stories/demo-data/leads.ts +2 -2
- package/src/stories/demo-data/recipes.ts +2 -2
- package/src/types/contract.test.ts +284 -0
- package/src/types/index.ts +339 -49
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/funnels",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.14",
|
|
4
4
|
"description": "Brutally generic filtering pipeline package for any Simpli product",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -38,25 +38,13 @@
|
|
|
38
38
|
"license": "MIT",
|
|
39
39
|
"repository": {
|
|
40
40
|
"type": "git",
|
|
41
|
-
"url": "https://github.com/
|
|
41
|
+
"url": "git+https://github.com/qosha1/start-simpli.git",
|
|
42
42
|
"directory": "packages/funnels"
|
|
43
43
|
},
|
|
44
44
|
"bugs": {
|
|
45
|
-
"url": "https://github.com/
|
|
46
|
-
},
|
|
47
|
-
"homepage": "https://github.com/simpli/start-simpli-api/tree/main/packages/funnels#readme",
|
|
48
|
-
"scripts": {
|
|
49
|
-
"build": "tsup",
|
|
50
|
-
"dev": "tsup --watch",
|
|
51
|
-
"type-check": "tsc --noEmit",
|
|
52
|
-
"test": "vitest run",
|
|
53
|
-
"test:watch": "vitest",
|
|
54
|
-
"test:coverage": "vitest run --coverage",
|
|
55
|
-
"clean": "rm -rf dist",
|
|
56
|
-
"storybook": "storybook dev -p 6006",
|
|
57
|
-
"build-storybook": "storybook build",
|
|
58
|
-
"storybook:ci": "storybook build --test"
|
|
45
|
+
"url": "https://github.com/qosha1/start-simpli/issues"
|
|
59
46
|
},
|
|
47
|
+
"homepage": "https://github.com/qosha1/start-simpli/tree/main/packages/funnels#readme",
|
|
60
48
|
"peerDependencies": {
|
|
61
49
|
"react": "^18.0.0 || ^19.0.0",
|
|
62
50
|
"react-dom": "^18.0.0 || ^19.0.0",
|
|
@@ -94,5 +82,17 @@
|
|
|
94
82
|
"@dnd-kit/sortable": "^8.0.0",
|
|
95
83
|
"@dnd-kit/utilities": "^3.2.2",
|
|
96
84
|
"@xyflow/react": "^12.10.2"
|
|
85
|
+
},
|
|
86
|
+
"scripts": {
|
|
87
|
+
"build": "tsup",
|
|
88
|
+
"dev": "tsup --watch",
|
|
89
|
+
"type-check": "tsc --noEmit",
|
|
90
|
+
"test": "vitest run",
|
|
91
|
+
"test:watch": "vitest",
|
|
92
|
+
"test:coverage": "vitest run --coverage",
|
|
93
|
+
"clean": "rm -rf dist",
|
|
94
|
+
"storybook": "storybook dev -p 6006",
|
|
95
|
+
"build-storybook": "storybook build",
|
|
96
|
+
"storybook:ci": "storybook build --test"
|
|
97
97
|
}
|
|
98
|
-
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FunnelApiClient against an injected path family (bd startsim-pxzlf).
|
|
3
|
+
*
|
|
4
|
+
* client.test.ts covers the default (central) family and the error handling.
|
|
5
|
+
* This file covers the part that was impossible before: pointing the SAME
|
|
6
|
+
* client at the control-plane route, a tenant proxy, or a fork's own mount.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
10
|
+
import { FunnelApiClient } from './client';
|
|
11
|
+
import { createFunnelPaths, foundryTenantFunnelPaths } from './paths';
|
|
12
|
+
import type { ApiAdapter } from './adapter';
|
|
13
|
+
|
|
14
|
+
class RecordingAdapter implements ApiAdapter {
|
|
15
|
+
public calls: { method: string; url: string; data?: unknown; params?: unknown }[] = [];
|
|
16
|
+
public nextResponse: unknown = {};
|
|
17
|
+
|
|
18
|
+
async get<T>(url: string, params?: Record<string, unknown>): Promise<T> {
|
|
19
|
+
this.calls.push({ method: 'GET', url, params });
|
|
20
|
+
return this.nextResponse as T;
|
|
21
|
+
}
|
|
22
|
+
async post<T>(url: string, data: unknown): Promise<T> {
|
|
23
|
+
this.calls.push({ method: 'POST', url, data });
|
|
24
|
+
return this.nextResponse as T;
|
|
25
|
+
}
|
|
26
|
+
async patch<T>(url: string, data: unknown): Promise<T> {
|
|
27
|
+
this.calls.push({ method: 'PATCH', url, data });
|
|
28
|
+
return this.nextResponse as T;
|
|
29
|
+
}
|
|
30
|
+
async delete<T>(url: string): Promise<T> {
|
|
31
|
+
this.calls.push({ method: 'DELETE', url });
|
|
32
|
+
return this.nextResponse as T;
|
|
33
|
+
}
|
|
34
|
+
get lastUrl(): string {
|
|
35
|
+
return this.calls[this.calls.length - 1]?.url ?? '';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('FunnelApiClient path injection', () => {
|
|
40
|
+
let adapter: RecordingAdapter;
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
adapter = new RecordingAdapter();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('keeps the central family when no paths are injected — the old constructor still works', async () => {
|
|
47
|
+
const client = new FunnelApiClient(adapter, 'https://api.startsimpli.com');
|
|
48
|
+
await client.listFunnels();
|
|
49
|
+
expect(adapter.lastUrl).toBe('https://api.startsimpli.com/api/v1/funnels/');
|
|
50
|
+
await client.getFunnelRun('r1');
|
|
51
|
+
expect(adapter.lastUrl).toBe('https://api.startsimpli.com/api/v1/funnel-runs/r1/');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('talks to a foundry control-plane route when given that family', async () => {
|
|
55
|
+
const client = new FunnelApiClient(adapter, '', foundryTenantFunnelPaths('acme'));
|
|
56
|
+
|
|
57
|
+
await client.listFunnels({ status: 'active' });
|
|
58
|
+
expect(adapter.lastUrl).toBe('/api/v1/foundry/tenants/acme/funnels/');
|
|
59
|
+
|
|
60
|
+
await client.getFunnel('f1');
|
|
61
|
+
expect(adapter.lastUrl).toBe('/api/v1/foundry/tenants/acme/funnels/f1/');
|
|
62
|
+
|
|
63
|
+
await client.runFunnel('f1');
|
|
64
|
+
expect(adapter.lastUrl).toBe('/api/v1/foundry/tenants/acme/funnels/f1/run/');
|
|
65
|
+
|
|
66
|
+
await client.getFunnelRuns('f1');
|
|
67
|
+
expect(adapter.lastUrl).toBe('/api/v1/foundry/tenants/acme/funnels/f1/runs/');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('addresses a nested run through its funnel on the control plane', async () => {
|
|
71
|
+
const client = new FunnelApiClient(adapter, '', foundryTenantFunnelPaths('acme'));
|
|
72
|
+
await client.getFunnelRun('r1', 'f1');
|
|
73
|
+
expect(adapter.lastUrl).toBe('/api/v1/foundry/tenants/acme/funnels/f1/runs/r1/');
|
|
74
|
+
await client.getFunnelResults('r1', { funnelId: 'f1' });
|
|
75
|
+
expect(adapter.lastUrl).toBe('/api/v1/foundry/tenants/acme/funnels/f1/runs/r1/results/');
|
|
76
|
+
await client.cancelFunnelRun('r1', 'f1');
|
|
77
|
+
expect(adapter.lastUrl).toBe('/api/v1/foundry/tenants/acme/funnels/f1/runs/r1/cancel/');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('talks through a tenant proxy prefix', async () => {
|
|
81
|
+
const client = new FunnelApiClient(
|
|
82
|
+
adapter,
|
|
83
|
+
'',
|
|
84
|
+
createFunnelPaths({
|
|
85
|
+
prefix: '/central-api/api/v1/funnels',
|
|
86
|
+
runsPrefix: '/central-api/api/v1/funnel-runs',
|
|
87
|
+
})
|
|
88
|
+
);
|
|
89
|
+
await client.listFunnels();
|
|
90
|
+
expect(adapter.lastUrl).toBe('/central-api/api/v1/funnels/');
|
|
91
|
+
await client.getFunnelRun('r1');
|
|
92
|
+
expect(adapter.lastUrl).toBe('/central-api/api/v1/funnel-runs/r1/');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('exposes the family it was built with, so a composer can tell the surfaces apart', () => {
|
|
96
|
+
const client = new FunnelApiClient(adapter, '', foundryTenantFunnelPaths('acme'));
|
|
97
|
+
expect(client.paths.prefix).toBe('/api/v1/foundry/tenants/acme/funnels');
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('FunnelApiClient.getFields', () => {
|
|
102
|
+
let adapter: RecordingAdapter;
|
|
103
|
+
|
|
104
|
+
beforeEach(() => {
|
|
105
|
+
adapter = new RecordingAdapter();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('fetches the field registry from the family it was pointed at', async () => {
|
|
109
|
+
adapter.nextResponse = { entity_type: 'contact', fields: [] };
|
|
110
|
+
const client = new FunnelApiClient(adapter, '', foundryTenantFunnelPaths('acme'));
|
|
111
|
+
await client.getFields('contact');
|
|
112
|
+
expect(adapter.lastUrl).toBe('/api/v1/foundry/tenants/acme/funnels/fields/');
|
|
113
|
+
expect(adapter.calls[0].params).toEqual({ entity_type: 'contact' });
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('normalizes the payload, whether or not the adapter camelizes it', async () => {
|
|
117
|
+
adapter.nextResponse = {
|
|
118
|
+
entity_type: 'contact',
|
|
119
|
+
fields: [
|
|
120
|
+
{
|
|
121
|
+
key: 'tag.stage_focus',
|
|
122
|
+
label: 'stage_focus',
|
|
123
|
+
category: 'Tags',
|
|
124
|
+
value_type: 'enum',
|
|
125
|
+
enum_values: ['seed'],
|
|
126
|
+
allowed_operators: ['eq', 'exists'],
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
};
|
|
130
|
+
const client = new FunnelApiClient(adapter, '');
|
|
131
|
+
const registry = await client.getFields();
|
|
132
|
+
|
|
133
|
+
expect(registry.entityType).toBe('contact');
|
|
134
|
+
expect(registry.fields[0]).toMatchObject({
|
|
135
|
+
key: 'tag.stage_focus',
|
|
136
|
+
valueType: 'enum',
|
|
137
|
+
enumValues: ['seed'],
|
|
138
|
+
allowedOperators: ['eq', 'exists'],
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('reads a camelized payload unchanged', async () => {
|
|
143
|
+
adapter.nextResponse = {
|
|
144
|
+
entityType: 'sc_artist',
|
|
145
|
+
fields: [
|
|
146
|
+
{
|
|
147
|
+
key: 'metric.reach.followers',
|
|
148
|
+
label: 'followers',
|
|
149
|
+
category: 'Metrics',
|
|
150
|
+
valueType: 'number',
|
|
151
|
+
enumValues: [],
|
|
152
|
+
allowedOperators: ['gte', 'between'],
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
};
|
|
156
|
+
const client = new FunnelApiClient(adapter, '');
|
|
157
|
+
const registry = await client.getFields('sc_artist');
|
|
158
|
+
|
|
159
|
+
expect(registry.entityType).toBe('sc_artist');
|
|
160
|
+
expect(registry.fields[0].allowedOperators).toEqual(['gte', 'between']);
|
|
161
|
+
});
|
|
162
|
+
});
|
package/src/api/client.ts
CHANGED
|
@@ -8,16 +8,21 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { ApiAdapter } from './adapter';
|
|
11
|
+
import type { FunnelPaths } from './paths';
|
|
12
|
+
import { centralFunnelPaths } from './paths';
|
|
11
13
|
import type {
|
|
12
14
|
Funnel,
|
|
13
15
|
FunnelStage,
|
|
14
16
|
FunnelRun,
|
|
15
17
|
FunnelResult,
|
|
18
|
+
FieldRegistry,
|
|
19
|
+
FieldDefinitionInput,
|
|
16
20
|
CreateFunnelInput,
|
|
17
21
|
UpdateFunnelInput,
|
|
18
22
|
CreateStageInput,
|
|
19
23
|
UpdateStageInput,
|
|
20
24
|
} from '../types';
|
|
25
|
+
import { normalizeFieldDefinition } from '../types';
|
|
21
26
|
|
|
22
27
|
/**
|
|
23
28
|
* List filters for funnels
|
|
@@ -89,7 +94,12 @@ export interface PreviewResult<TEntity = any> {
|
|
|
89
94
|
* Usage:
|
|
90
95
|
* ```ts
|
|
91
96
|
* const adapter = new FetchAdapter({ headers: { 'Authorization': 'Bearer token' } });
|
|
92
|
-
*
|
|
97
|
+
*
|
|
98
|
+
* // the central API
|
|
99
|
+
* const client = new FunnelApiClient(adapter, 'https://api.startsimpli.com');
|
|
100
|
+
*
|
|
101
|
+
* // a foundry's funnels on the control plane (startsim-9xbz2)
|
|
102
|
+
* const tenant = new FunnelApiClient(adapter, '', foundryTenantFunnelPaths('acme'));
|
|
93
103
|
*
|
|
94
104
|
* const funnels = await client.listFunnels({ status: 'active' });
|
|
95
105
|
* const funnel = await client.getFunnel('funnel-123');
|
|
@@ -97,12 +107,17 @@ export interface PreviewResult<TEntity = any> {
|
|
|
97
107
|
* ```
|
|
98
108
|
*/
|
|
99
109
|
export class FunnelApiClient {
|
|
110
|
+
/** The path family this client talks to. Readable, so a caller can log it. */
|
|
111
|
+
public readonly paths: FunnelPaths;
|
|
112
|
+
|
|
100
113
|
constructor(
|
|
101
114
|
private adapter: ApiAdapter,
|
|
102
|
-
private baseUrl: string
|
|
115
|
+
private baseUrl: string,
|
|
116
|
+
paths: FunnelPaths = centralFunnelPaths
|
|
103
117
|
) {
|
|
104
118
|
// Remove trailing slash
|
|
105
119
|
this.baseUrl = baseUrl.replace(/\/$/, '');
|
|
120
|
+
this.paths = paths;
|
|
106
121
|
}
|
|
107
122
|
|
|
108
123
|
/**
|
|
@@ -126,7 +141,7 @@ export class FunnelApiClient {
|
|
|
126
141
|
filters?: FunnelListFilters
|
|
127
142
|
): Promise<PaginatedResponse<Funnel<TEntity>>> {
|
|
128
143
|
return this.adapter.get<PaginatedResponse<Funnel<TEntity>>>(
|
|
129
|
-
this.url(
|
|
144
|
+
this.url(this.paths.list()),
|
|
130
145
|
filters
|
|
131
146
|
);
|
|
132
147
|
}
|
|
@@ -139,7 +154,7 @@ export class FunnelApiClient {
|
|
|
139
154
|
* @throws ApiError with status 404 if not found
|
|
140
155
|
*/
|
|
141
156
|
async getFunnel<TEntity = any>(id: string): Promise<Funnel<TEntity>> {
|
|
142
|
-
return this.adapter.get<Funnel<TEntity>>(this.url(
|
|
157
|
+
return this.adapter.get<Funnel<TEntity>>(this.url(this.paths.detail(id)));
|
|
143
158
|
}
|
|
144
159
|
|
|
145
160
|
/**
|
|
@@ -152,7 +167,7 @@ export class FunnelApiClient {
|
|
|
152
167
|
async createFunnel<TEntity = any>(
|
|
153
168
|
data: CreateFunnelInput<TEntity>
|
|
154
169
|
): Promise<Funnel<TEntity>> {
|
|
155
|
-
return this.adapter.post<Funnel<TEntity>>(this.url(
|
|
170
|
+
return this.adapter.post<Funnel<TEntity>>(this.url(this.paths.list()), data);
|
|
156
171
|
}
|
|
157
172
|
|
|
158
173
|
/**
|
|
@@ -168,7 +183,7 @@ export class FunnelApiClient {
|
|
|
168
183
|
data: Partial<UpdateFunnelInput<TEntity>>
|
|
169
184
|
): Promise<Funnel<TEntity>> {
|
|
170
185
|
return this.adapter.patch<Funnel<TEntity>>(
|
|
171
|
-
this.url(
|
|
186
|
+
this.url(this.paths.detail(id)),
|
|
172
187
|
data
|
|
173
188
|
);
|
|
174
189
|
}
|
|
@@ -180,7 +195,7 @@ export class FunnelApiClient {
|
|
|
180
195
|
* @throws ApiError with status 404 if not found
|
|
181
196
|
*/
|
|
182
197
|
async deleteFunnel(id: string): Promise<void> {
|
|
183
|
-
return this.adapter.delete<void>(this.url(
|
|
198
|
+
return this.adapter.delete<void>(this.url(this.paths.detail(id)));
|
|
184
199
|
}
|
|
185
200
|
|
|
186
201
|
// ============================================================================
|
|
@@ -200,7 +215,7 @@ export class FunnelApiClient {
|
|
|
200
215
|
data: CreateStageInput<TEntity>
|
|
201
216
|
): Promise<FunnelStage<TEntity>> {
|
|
202
217
|
return this.adapter.post<FunnelStage<TEntity>>(
|
|
203
|
-
this.url(
|
|
218
|
+
this.url(this.paths.stages(funnelId)),
|
|
204
219
|
data
|
|
205
220
|
);
|
|
206
221
|
}
|
|
@@ -220,7 +235,7 @@ export class FunnelApiClient {
|
|
|
220
235
|
data: Partial<UpdateStageInput<TEntity>>
|
|
221
236
|
): Promise<FunnelStage<TEntity>> {
|
|
222
237
|
return this.adapter.patch<FunnelStage<TEntity>>(
|
|
223
|
-
this.url(
|
|
238
|
+
this.url(this.paths.stage(funnelId, stageId)),
|
|
224
239
|
data
|
|
225
240
|
);
|
|
226
241
|
}
|
|
@@ -234,7 +249,7 @@ export class FunnelApiClient {
|
|
|
234
249
|
*/
|
|
235
250
|
async deleteStage(funnelId: string, stageId: string): Promise<void> {
|
|
236
251
|
return this.adapter.delete<void>(
|
|
237
|
-
this.url(
|
|
252
|
+
this.url(this.paths.stage(funnelId, stageId))
|
|
238
253
|
);
|
|
239
254
|
}
|
|
240
255
|
|
|
@@ -258,7 +273,7 @@ export class FunnelApiClient {
|
|
|
258
273
|
}
|
|
259
274
|
): Promise<FunnelRun> {
|
|
260
275
|
return this.adapter.post<FunnelRun>(
|
|
261
|
-
this.url(
|
|
276
|
+
this.url(this.paths.run(funnelId)),
|
|
262
277
|
options || {}
|
|
263
278
|
);
|
|
264
279
|
}
|
|
@@ -281,7 +296,7 @@ export class FunnelApiClient {
|
|
|
281
296
|
}
|
|
282
297
|
): Promise<PaginatedResponse<FunnelRun>> {
|
|
283
298
|
return this.adapter.get<PaginatedResponse<FunnelRun>>(
|
|
284
|
-
this.url(
|
|
299
|
+
this.url(this.paths.runs(funnelId)),
|
|
285
300
|
filters
|
|
286
301
|
);
|
|
287
302
|
}
|
|
@@ -290,11 +305,13 @@ export class FunnelApiClient {
|
|
|
290
305
|
* Get single run detail
|
|
291
306
|
*
|
|
292
307
|
* @param runId - Run ID
|
|
308
|
+
* @param funnelId - Required only on a path family that nests runs under
|
|
309
|
+
* their funnel (the control plane); ignored on the central family.
|
|
293
310
|
* @returns Funnel run detail
|
|
294
311
|
* @throws ApiError with status 404 if not found
|
|
295
312
|
*/
|
|
296
|
-
async getFunnelRun(runId: string): Promise<FunnelRun> {
|
|
297
|
-
return this.adapter.get<FunnelRun>(this.url(
|
|
313
|
+
async getFunnelRun(runId: string, funnelId?: string): Promise<FunnelRun> {
|
|
314
|
+
return this.adapter.get<FunnelRun>(this.url(this.paths.runDetail(runId, funnelId)));
|
|
298
315
|
}
|
|
299
316
|
|
|
300
317
|
/**
|
|
@@ -311,11 +328,14 @@ export class FunnelApiClient {
|
|
|
311
328
|
matched?: boolean;
|
|
312
329
|
page?: number;
|
|
313
330
|
pageSize?: number;
|
|
331
|
+
/** Required only on a family that nests runs under their funnel. */
|
|
332
|
+
funnelId?: string;
|
|
314
333
|
}
|
|
315
334
|
): Promise<PaginatedResponse<FunnelResult<TEntity>>> {
|
|
335
|
+
const { funnelId, ...query } = filters ?? {};
|
|
316
336
|
return this.adapter.get<PaginatedResponse<FunnelResult<TEntity>>>(
|
|
317
|
-
this.url(
|
|
318
|
-
|
|
337
|
+
this.url(this.paths.runResults(runId, funnelId)),
|
|
338
|
+
query
|
|
319
339
|
);
|
|
320
340
|
}
|
|
321
341
|
|
|
@@ -326,13 +346,46 @@ export class FunnelApiClient {
|
|
|
326
346
|
* @returns Updated run with status 'cancelled'
|
|
327
347
|
* @throws ApiError with status 404 if not found, 400 if already completed
|
|
328
348
|
*/
|
|
329
|
-
async cancelFunnelRun(runId: string): Promise<FunnelRun> {
|
|
349
|
+
async cancelFunnelRun(runId: string, funnelId?: string): Promise<FunnelRun> {
|
|
330
350
|
return this.adapter.post<FunnelRun>(
|
|
331
|
-
this.url(
|
|
351
|
+
this.url(this.paths.runCancel(runId, funnelId)),
|
|
332
352
|
{}
|
|
333
353
|
);
|
|
334
354
|
}
|
|
335
355
|
|
|
356
|
+
// ============================================================================
|
|
357
|
+
// Field Registry
|
|
358
|
+
// ============================================================================
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Fetch the field registry that describes which keys a rule may name.
|
|
362
|
+
*
|
|
363
|
+
* This is what a rule editor should be driven by — the server's own resolver
|
|
364
|
+
* registry (backend/apps/funnels/resolvers/registry.py), not a hand-written
|
|
365
|
+
* per-app list that drifts from it.
|
|
366
|
+
*
|
|
367
|
+
* The payload is normalized, so the same call works whether the injected
|
|
368
|
+
* adapter camelizes keys (the @startsimpli/api FetchWrapper does) or hands
|
|
369
|
+
* back the raw snake_case body (FetchAdapter does).
|
|
370
|
+
*
|
|
371
|
+
* @param entityType - Optional: only the fields that apply to this entity type
|
|
372
|
+
*/
|
|
373
|
+
async getFields(entityType?: string): Promise<FieldRegistry> {
|
|
374
|
+
const response = await this.adapter.get<{
|
|
375
|
+
entity_type?: string | null;
|
|
376
|
+
entityType?: string | null;
|
|
377
|
+
fields?: FieldDefinitionInput[];
|
|
378
|
+
}>(
|
|
379
|
+
this.url(this.paths.fields()),
|
|
380
|
+
entityType ? { entity_type: entityType } : undefined
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
return {
|
|
384
|
+
entityType: response.entityType ?? response.entity_type ?? null,
|
|
385
|
+
fields: (response.fields ?? []).map(normalizeFieldDefinition),
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
336
389
|
// ============================================================================
|
|
337
390
|
// Client-Side Preview (Local Evaluation)
|
|
338
391
|
// ============================================================================
|
|
@@ -378,7 +431,7 @@ export class FunnelApiClient {
|
|
|
378
431
|
sampleEntities: TEntity[]
|
|
379
432
|
): Promise<PreviewResult<TEntity>> {
|
|
380
433
|
return this.adapter.post<PreviewResult<TEntity>>(
|
|
381
|
-
this.url(
|
|
434
|
+
this.url(this.paths.preview(funnelId)),
|
|
382
435
|
{ entities: sampleEntities }
|
|
383
436
|
);
|
|
384
437
|
}
|
package/src/api/index.ts
CHANGED
|
@@ -15,6 +15,17 @@ export { createApiError, isApiError } from './adapter';
|
|
|
15
15
|
export { FetchAdapter } from './default-adapter';
|
|
16
16
|
export type { FetchAdapterConfig } from './default-adapter';
|
|
17
17
|
|
|
18
|
+
// Path families — the surfaces the same client can talk to (bd startsim-pxzlf)
|
|
19
|
+
export {
|
|
20
|
+
createFunnelPaths,
|
|
21
|
+
centralFunnelPaths,
|
|
22
|
+
foundryTenantFunnelPaths,
|
|
23
|
+
proxiedCentralFunnelPaths,
|
|
24
|
+
DEFAULT_FUNNEL_PATH_PREFIX,
|
|
25
|
+
DEFAULT_FUNNEL_RUNS_PATH_PREFIX,
|
|
26
|
+
} from './paths';
|
|
27
|
+
export type { FunnelPaths, FunnelPathsOptions } from './paths';
|
|
28
|
+
|
|
18
29
|
// Client
|
|
19
30
|
export { FunnelApiClient } from './client';
|
|
20
31
|
export type {
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the injectable funnel path family (bd startsim-pxzlf).
|
|
3
|
+
*
|
|
4
|
+
* The same client has to talk to four surfaces that differ ONLY in their URL
|
|
5
|
+
* prefix and, for the control plane, in whether a run is addressed globally or
|
|
6
|
+
* under its funnel:
|
|
7
|
+
*
|
|
8
|
+
* central /api/v1/funnels/… + /api/v1/funnel-runs/<id>/
|
|
9
|
+
* control plane /api/v1/foundry/tenants/<slug>/funnels/… (runs nested)
|
|
10
|
+
* tenant proxy /central-api/api/v1/funnels/… (a prefix on the central family)
|
|
11
|
+
* a fork's own whatever the fork mounted
|
|
12
|
+
*
|
|
13
|
+
* Hardcoding '/api/v1/funnels' in the client (api/client.ts before this change)
|
|
14
|
+
* made the first the only one reachable.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, it, expect } from 'vitest';
|
|
18
|
+
import {
|
|
19
|
+
createFunnelPaths,
|
|
20
|
+
centralFunnelPaths,
|
|
21
|
+
foundryTenantFunnelPaths,
|
|
22
|
+
DEFAULT_FUNNEL_PATH_PREFIX,
|
|
23
|
+
} from './paths';
|
|
24
|
+
|
|
25
|
+
describe('centralFunnelPaths', () => {
|
|
26
|
+
it('is the /api/v1/funnels family the client used to hardcode', () => {
|
|
27
|
+
expect(centralFunnelPaths.list()).toBe('/api/v1/funnels/');
|
|
28
|
+
expect(centralFunnelPaths.detail('f1')).toBe('/api/v1/funnels/f1/');
|
|
29
|
+
expect(centralFunnelPaths.stages('f1')).toBe('/api/v1/funnels/f1/stages/');
|
|
30
|
+
expect(centralFunnelPaths.stage('f1', 's1')).toBe('/api/v1/funnels/f1/stages/s1/');
|
|
31
|
+
expect(centralFunnelPaths.run('f1')).toBe('/api/v1/funnels/f1/run/');
|
|
32
|
+
expect(centralFunnelPaths.runs('f1')).toBe('/api/v1/funnels/f1/runs/');
|
|
33
|
+
expect(centralFunnelPaths.preview('f1')).toBe('/api/v1/funnels/f1/preview/');
|
|
34
|
+
expect(centralFunnelPaths.fields()).toBe('/api/v1/funnels/fields/');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('addresses a run globally, because the central API does', () => {
|
|
38
|
+
// config/api_router.py registers funnel-runs as its own router entry.
|
|
39
|
+
expect(centralFunnelPaths.runDetail('r1')).toBe('/api/v1/funnel-runs/r1/');
|
|
40
|
+
expect(centralFunnelPaths.runResults('r1')).toBe('/api/v1/funnel-runs/r1/results/');
|
|
41
|
+
expect(centralFunnelPaths.runCancel('r1')).toBe('/api/v1/funnel-runs/r1/cancel/');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('exposes its prefix so callers can see which surface they are on', () => {
|
|
45
|
+
expect(centralFunnelPaths.prefix).toBe(DEFAULT_FUNNEL_PATH_PREFIX);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('createFunnelPaths', () => {
|
|
50
|
+
it('defaults to the central family', () => {
|
|
51
|
+
const paths = createFunnelPaths();
|
|
52
|
+
expect(paths.list()).toBe(centralFunnelPaths.list());
|
|
53
|
+
expect(paths.runDetail('r1')).toBe(centralFunnelPaths.runDetail('r1'));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('mounts the whole family under a prefix — a tenant proxy', () => {
|
|
57
|
+
const paths = createFunnelPaths({ prefix: '/central-api/api/v1/funnels' });
|
|
58
|
+
expect(paths.list()).toBe('/central-api/api/v1/funnels/');
|
|
59
|
+
expect(paths.detail('f1')).toBe('/central-api/api/v1/funnels/f1/');
|
|
60
|
+
expect(paths.fields()).toBe('/central-api/api/v1/funnels/fields/');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('takes the runs prefix separately, since runs need not live under funnels', () => {
|
|
64
|
+
const paths = createFunnelPaths({
|
|
65
|
+
prefix: '/central-api/api/v1/funnels',
|
|
66
|
+
runsPrefix: '/central-api/api/v1/funnel-runs',
|
|
67
|
+
});
|
|
68
|
+
expect(paths.runDetail('r1')).toBe('/central-api/api/v1/funnel-runs/r1/');
|
|
69
|
+
expect(paths.runCancel('r1')).toBe('/central-api/api/v1/funnel-runs/r1/cancel/');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('nests runs under their funnel when asked (the control-plane shape)', () => {
|
|
73
|
+
const paths = createFunnelPaths({
|
|
74
|
+
prefix: '/api/v1/foundry/tenants/acme/funnels',
|
|
75
|
+
nestRuns: true,
|
|
76
|
+
});
|
|
77
|
+
expect(paths.runDetail('r1', 'f1')).toBe(
|
|
78
|
+
'/api/v1/foundry/tenants/acme/funnels/f1/runs/r1/'
|
|
79
|
+
);
|
|
80
|
+
expect(paths.runResults('r1', 'f1')).toBe(
|
|
81
|
+
'/api/v1/foundry/tenants/acme/funnels/f1/runs/r1/results/'
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('refuses a nested run address with no funnel, instead of building a wrong URL', () => {
|
|
86
|
+
const paths = createFunnelPaths({ prefix: '/api/v1/x/funnels', nestRuns: true });
|
|
87
|
+
expect(() => paths.runDetail('r1')).toThrow(/funnel/i);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('normalizes a prefix given with or without a trailing slash', () => {
|
|
91
|
+
const withSlash = createFunnelPaths({ prefix: '/api/v1/funnels/' });
|
|
92
|
+
const without = createFunnelPaths({ prefix: '/api/v1/funnels' });
|
|
93
|
+
expect(withSlash.list()).toBe(without.list());
|
|
94
|
+
expect(withSlash.detail('f1')).toBe(without.detail('f1'));
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('URL-encodes ids so a slug or an id with a slash cannot escape the family', () => {
|
|
98
|
+
const paths = createFunnelPaths();
|
|
99
|
+
expect(paths.detail('a/b')).toBe('/api/v1/funnels/a%2Fb/');
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('foundryTenantFunnelPaths', () => {
|
|
104
|
+
it('is the control-plane family for one foundry (startsim-9xbz2)', () => {
|
|
105
|
+
const paths = foundryTenantFunnelPaths('acme');
|
|
106
|
+
expect(paths.list()).toBe('/api/v1/foundry/tenants/acme/funnels/');
|
|
107
|
+
expect(paths.detail('f1')).toBe('/api/v1/foundry/tenants/acme/funnels/f1/');
|
|
108
|
+
expect(paths.run('f1')).toBe('/api/v1/foundry/tenants/acme/funnels/f1/run/');
|
|
109
|
+
expect(paths.runs('f1')).toBe('/api/v1/foundry/tenants/acme/funnels/f1/runs/');
|
|
110
|
+
expect(paths.fields()).toBe('/api/v1/foundry/tenants/acme/funnels/fields/');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('nests runs under the funnel, which is how the nested viewset addresses them', () => {
|
|
114
|
+
const paths = foundryTenantFunnelPaths('acme');
|
|
115
|
+
expect(paths.runDetail('r1', 'f1')).toBe(
|
|
116
|
+
'/api/v1/foundry/tenants/acme/funnels/f1/runs/r1/'
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('encodes the slug', () => {
|
|
121
|
+
expect(foundryTenantFunnelPaths('a b').list()).toBe(
|
|
122
|
+
'/api/v1/foundry/tenants/a%20b/funnels/'
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
});
|