@startsimpli/funnels 0.4.13 → 0.4.15
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 +24 -16
- package/src/api/client.paths.test.ts +175 -0
- package/src/api/client.ts +78 -19
- package/src/api/http-adapter.test.ts +68 -0
- package/src/api/http-adapter.ts +48 -0
- package/src/api/index.ts +16 -0
- package/src/api/paths.test.ts +138 -0
- package/src/api/paths.ts +192 -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/page/FunnelsPage.test.tsx +383 -0
- package/src/page/FunnelsPage.tsx +739 -0
- package/src/page/index.ts +24 -0
- package/src/page/types.ts +102 -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,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/funnels",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.15",
|
|
4
4
|
"description": "Brutally generic filtering pipeline package for any Simpli product",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
7
7
|
"types": "./src/index.ts",
|
|
8
8
|
"exports": {
|
|
9
9
|
".": "./src/index.ts",
|
|
10
|
+
"./page": "./src/page/index.ts",
|
|
10
11
|
"./core": "./src/core/index.ts",
|
|
11
12
|
"./components": "./src/components/index.ts",
|
|
12
13
|
"./hooks": "./src/hooks/index.ts",
|
|
@@ -45,22 +46,11 @@
|
|
|
45
46
|
"url": "https://github.com/qosha1/start-simpli/issues"
|
|
46
47
|
},
|
|
47
48
|
"homepage": "https://github.com/qosha1/start-simpli/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"
|
|
59
|
-
},
|
|
60
49
|
"peerDependencies": {
|
|
61
50
|
"react": "^18.0.0 || ^19.0.0",
|
|
62
51
|
"react-dom": "^18.0.0 || ^19.0.0",
|
|
63
|
-
"zustand": "^4.0.0 || ^5.0.0"
|
|
52
|
+
"zustand": "^4.0.0 || ^5.0.0",
|
|
53
|
+
"@startsimpli/ui": "^0.4.125"
|
|
64
54
|
},
|
|
65
55
|
"devDependencies": {
|
|
66
56
|
"@chromatic-com/storybook": "^5.1.2",
|
|
@@ -87,12 +77,30 @@
|
|
|
87
77
|
"tsup": "^8.5.1",
|
|
88
78
|
"typescript": "^6.0.3",
|
|
89
79
|
"vitest": "^4.1.5",
|
|
90
|
-
"zustand": "^5.0.12"
|
|
80
|
+
"zustand": "^5.0.12",
|
|
81
|
+
"@startsimpli/ui": "0.4.125"
|
|
91
82
|
},
|
|
92
83
|
"dependencies": {
|
|
93
84
|
"@dnd-kit/core": "^6.3.1",
|
|
94
85
|
"@dnd-kit/sortable": "^8.0.0",
|
|
95
86
|
"@dnd-kit/utilities": "^3.2.2",
|
|
96
87
|
"@xyflow/react": "^12.10.2"
|
|
88
|
+
},
|
|
89
|
+
"peerDependenciesMeta": {
|
|
90
|
+
"@startsimpli/ui": {
|
|
91
|
+
"optional": true
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
"scripts": {
|
|
95
|
+
"build": "tsup",
|
|
96
|
+
"dev": "tsup --watch",
|
|
97
|
+
"type-check": "tsc --noEmit",
|
|
98
|
+
"test": "vitest run",
|
|
99
|
+
"test:watch": "vitest",
|
|
100
|
+
"test:coverage": "vitest run --coverage",
|
|
101
|
+
"clean": "rm -rf dist",
|
|
102
|
+
"storybook": "storybook dev -p 6006",
|
|
103
|
+
"build-storybook": "storybook build",
|
|
104
|
+
"storybook:ci": "storybook build --test"
|
|
97
105
|
}
|
|
98
|
-
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
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
|
+
// Triggering is a POST to the run COLLECTION on this surface: the control
|
|
64
|
+
// plane mounts ONE `funnel_runs` action where GET lists and POST triggers.
|
|
65
|
+
await client.runFunnel('f1');
|
|
66
|
+
expect(adapter.lastUrl).toBe('/api/v1/foundry/tenants/acme/funnels/f1/runs/');
|
|
67
|
+
|
|
68
|
+
await client.getFunnelRuns('f1');
|
|
69
|
+
expect(adapter.lastUrl).toBe('/api/v1/foundry/tenants/acme/funnels/f1/runs/');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('addresses a run on the control plane’s sibling funnel-runs collection', async () => {
|
|
73
|
+
// Corrected in bd startsim-em5mn. The action is
|
|
74
|
+
// `funnel-runs/(?P<run_id>[^/.]+)/results` — a SIBLING of `funnels/`,
|
|
75
|
+
// because `runs/` on FoundryViewSet is already the foundry's own build
|
|
76
|
+
// history. The previous `nestRuns: true` built `funnels/f1/runs/r1/…`,
|
|
77
|
+
// which resolves to no route at all (verified against prod 2026-09-18).
|
|
78
|
+
const client = new FunnelApiClient(adapter, '', foundryTenantFunnelPaths('acme'));
|
|
79
|
+
await client.getFunnelResults('r1', { funnelId: 'f1' });
|
|
80
|
+
expect(adapter.lastUrl).toBe('/api/v1/foundry/tenants/acme/funnel-runs/r1/results/');
|
|
81
|
+
// ...and the funnel id is not needed to address it.
|
|
82
|
+
await client.getFunnelResults('r1');
|
|
83
|
+
expect(adapter.lastUrl).toBe('/api/v1/foundry/tenants/acme/funnel-runs/r1/results/');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('carries the stage-reached filter through as a query param', async () => {
|
|
87
|
+
const client = new FunnelApiClient(adapter, '', foundryTenantFunnelPaths('acme'));
|
|
88
|
+
await client.getFunnelResults('r1', { stage: 's1', matched: false, funnelId: 'f1' });
|
|
89
|
+
// `funnelId` addresses the URL and must not leak into the query string.
|
|
90
|
+
expect(adapter.calls.at(-1)!.params).toEqual({ stage: 's1', matched: false });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('talks through a tenant proxy prefix', async () => {
|
|
94
|
+
const client = new FunnelApiClient(
|
|
95
|
+
adapter,
|
|
96
|
+
'',
|
|
97
|
+
createFunnelPaths({
|
|
98
|
+
prefix: '/central-api/api/v1/funnels',
|
|
99
|
+
runsPrefix: '/central-api/api/v1/funnel-runs',
|
|
100
|
+
})
|
|
101
|
+
);
|
|
102
|
+
await client.listFunnels();
|
|
103
|
+
expect(adapter.lastUrl).toBe('/central-api/api/v1/funnels/');
|
|
104
|
+
await client.getFunnelRun('r1');
|
|
105
|
+
expect(adapter.lastUrl).toBe('/central-api/api/v1/funnel-runs/r1/');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('exposes the family it was built with, so a composer can tell the surfaces apart', () => {
|
|
109
|
+
const client = new FunnelApiClient(adapter, '', foundryTenantFunnelPaths('acme'));
|
|
110
|
+
expect(client.paths.prefix).toBe('/api/v1/foundry/tenants/acme/funnels');
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('FunnelApiClient.getFields', () => {
|
|
115
|
+
let adapter: RecordingAdapter;
|
|
116
|
+
|
|
117
|
+
beforeEach(() => {
|
|
118
|
+
adapter = new RecordingAdapter();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('fetches the field registry from the family it was pointed at', async () => {
|
|
122
|
+
adapter.nextResponse = { entity_type: 'contact', fields: [] };
|
|
123
|
+
const client = new FunnelApiClient(adapter, '', foundryTenantFunnelPaths('acme'));
|
|
124
|
+
await client.getFields('contact');
|
|
125
|
+
expect(adapter.lastUrl).toBe('/api/v1/foundry/tenants/acme/funnels/fields/');
|
|
126
|
+
expect(adapter.calls[0].params).toEqual({ entity_type: 'contact' });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('normalizes the payload, whether or not the adapter camelizes it', async () => {
|
|
130
|
+
adapter.nextResponse = {
|
|
131
|
+
entity_type: 'contact',
|
|
132
|
+
fields: [
|
|
133
|
+
{
|
|
134
|
+
key: 'tag.stage_focus',
|
|
135
|
+
label: 'stage_focus',
|
|
136
|
+
category: 'Tags',
|
|
137
|
+
value_type: 'enum',
|
|
138
|
+
enum_values: ['seed'],
|
|
139
|
+
allowed_operators: ['eq', 'exists'],
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
};
|
|
143
|
+
const client = new FunnelApiClient(adapter, '');
|
|
144
|
+
const registry = await client.getFields();
|
|
145
|
+
|
|
146
|
+
expect(registry.entityType).toBe('contact');
|
|
147
|
+
expect(registry.fields[0]).toMatchObject({
|
|
148
|
+
key: 'tag.stage_focus',
|
|
149
|
+
valueType: 'enum',
|
|
150
|
+
enumValues: ['seed'],
|
|
151
|
+
allowedOperators: ['eq', 'exists'],
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('reads a camelized payload unchanged', async () => {
|
|
156
|
+
adapter.nextResponse = {
|
|
157
|
+
entityType: 'sc_artist',
|
|
158
|
+
fields: [
|
|
159
|
+
{
|
|
160
|
+
key: 'metric.reach.followers',
|
|
161
|
+
label: 'followers',
|
|
162
|
+
category: 'Metrics',
|
|
163
|
+
valueType: 'number',
|
|
164
|
+
enumValues: [],
|
|
165
|
+
allowedOperators: ['gte', 'between'],
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
};
|
|
169
|
+
const client = new FunnelApiClient(adapter, '');
|
|
170
|
+
const registry = await client.getFields('sc_artist');
|
|
171
|
+
|
|
172
|
+
expect(registry.entityType).toBe('sc_artist');
|
|
173
|
+
expect(registry.fields[0].allowedOperators).toEqual(['gte', 'between']);
|
|
174
|
+
});
|
|
175
|
+
});
|
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
|
/**
|
|
@@ -309,13 +326,22 @@ export class FunnelApiClient {
|
|
|
309
326
|
runId: string,
|
|
310
327
|
filters?: {
|
|
311
328
|
matched?: boolean;
|
|
329
|
+
/**
|
|
330
|
+
* WHERE THE ENTITY STOPPED. A stage id is "excluded at that stage";
|
|
331
|
+
* the literal 'end' is "was never excluded" (bd startsim-9xbz2). Served
|
|
332
|
+
* by the control-plane results action; the central one ignores it.
|
|
333
|
+
*/
|
|
334
|
+
stage?: string;
|
|
312
335
|
page?: number;
|
|
313
336
|
pageSize?: number;
|
|
337
|
+
/** Required only on a family that nests runs under their funnel. */
|
|
338
|
+
funnelId?: string;
|
|
314
339
|
}
|
|
315
340
|
): Promise<PaginatedResponse<FunnelResult<TEntity>>> {
|
|
341
|
+
const { funnelId, ...query } = filters ?? {};
|
|
316
342
|
return this.adapter.get<PaginatedResponse<FunnelResult<TEntity>>>(
|
|
317
|
-
this.url(
|
|
318
|
-
|
|
343
|
+
this.url(this.paths.runResults(runId, funnelId)),
|
|
344
|
+
query
|
|
319
345
|
);
|
|
320
346
|
}
|
|
321
347
|
|
|
@@ -326,13 +352,46 @@ export class FunnelApiClient {
|
|
|
326
352
|
* @returns Updated run with status 'cancelled'
|
|
327
353
|
* @throws ApiError with status 404 if not found, 400 if already completed
|
|
328
354
|
*/
|
|
329
|
-
async cancelFunnelRun(runId: string): Promise<FunnelRun> {
|
|
355
|
+
async cancelFunnelRun(runId: string, funnelId?: string): Promise<FunnelRun> {
|
|
330
356
|
return this.adapter.post<FunnelRun>(
|
|
331
|
-
this.url(
|
|
357
|
+
this.url(this.paths.runCancel(runId, funnelId)),
|
|
332
358
|
{}
|
|
333
359
|
);
|
|
334
360
|
}
|
|
335
361
|
|
|
362
|
+
// ============================================================================
|
|
363
|
+
// Field Registry
|
|
364
|
+
// ============================================================================
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Fetch the field registry that describes which keys a rule may name.
|
|
368
|
+
*
|
|
369
|
+
* This is what a rule editor should be driven by — the server's own resolver
|
|
370
|
+
* registry (backend/apps/funnels/resolvers/registry.py), not a hand-written
|
|
371
|
+
* per-app list that drifts from it.
|
|
372
|
+
*
|
|
373
|
+
* The payload is normalized, so the same call works whether the injected
|
|
374
|
+
* adapter camelizes keys (the @startsimpli/api FetchWrapper does) or hands
|
|
375
|
+
* back the raw snake_case body (FetchAdapter does).
|
|
376
|
+
*
|
|
377
|
+
* @param entityType - Optional: only the fields that apply to this entity type
|
|
378
|
+
*/
|
|
379
|
+
async getFields(entityType?: string): Promise<FieldRegistry> {
|
|
380
|
+
const response = await this.adapter.get<{
|
|
381
|
+
entity_type?: string | null;
|
|
382
|
+
entityType?: string | null;
|
|
383
|
+
fields?: FieldDefinitionInput[];
|
|
384
|
+
}>(
|
|
385
|
+
this.url(this.paths.fields()),
|
|
386
|
+
entityType ? { entity_type: entityType } : undefined
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
return {
|
|
390
|
+
entityType: response.entityType ?? response.entity_type ?? null,
|
|
391
|
+
fields: (response.fields ?? []).map(normalizeFieldDefinition),
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
336
395
|
// ============================================================================
|
|
337
396
|
// Client-Side Preview (Local Evaluation)
|
|
338
397
|
// ============================================================================
|
|
@@ -378,7 +437,7 @@ export class FunnelApiClient {
|
|
|
378
437
|
sampleEntities: TEntity[]
|
|
379
438
|
): Promise<PreviewResult<TEntity>> {
|
|
380
439
|
return this.adapter.post<PreviewResult<TEntity>>(
|
|
381
|
-
this.url(
|
|
440
|
+
this.url(this.paths.preview(funnelId)),
|
|
382
441
|
{ entities: sampleEntities }
|
|
383
442
|
);
|
|
384
443
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bridge from an app's own http client to the funnel adapter
|
|
3
|
+
* (bd startsim-em5mn).
|
|
4
|
+
*/
|
|
5
|
+
import { describe, it, expect } from 'vitest';
|
|
6
|
+
import { funnelHttpAdapter } from './http-adapter';
|
|
7
|
+
import { FunnelApiClient } from './client';
|
|
8
|
+
import { foundryTenantFunnelPaths } from './paths';
|
|
9
|
+
|
|
10
|
+
function spy() {
|
|
11
|
+
const calls: Array<{ method: string; endpoint: string; arg?: unknown }> = [];
|
|
12
|
+
const http = {
|
|
13
|
+
get: <T,>(endpoint: string, options?: { params?: Record<string, unknown> }) => {
|
|
14
|
+
calls.push({ method: 'get', endpoint, arg: options });
|
|
15
|
+
return Promise.resolve({ results: [], count: 0 } as unknown as T);
|
|
16
|
+
},
|
|
17
|
+
post: <T,>(endpoint: string, data?: unknown) => {
|
|
18
|
+
calls.push({ method: 'post', endpoint, arg: data });
|
|
19
|
+
return Promise.resolve({} as T);
|
|
20
|
+
},
|
|
21
|
+
patch: <T,>(endpoint: string, data?: unknown) => {
|
|
22
|
+
calls.push({ method: 'patch', endpoint, arg: data });
|
|
23
|
+
return Promise.resolve({} as T);
|
|
24
|
+
},
|
|
25
|
+
delete: <T,>(endpoint: string) => {
|
|
26
|
+
calls.push({ method: 'delete', endpoint });
|
|
27
|
+
return Promise.resolve(undefined as T);
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
return { http, calls };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('funnelHttpAdapter', () => {
|
|
34
|
+
it('hands query params to the http client under `params`', async () => {
|
|
35
|
+
const { http, calls } = spy();
|
|
36
|
+
const client = new FunnelApiClient(funnelHttpAdapter(http), '', foundryTenantFunnelPaths('acme'));
|
|
37
|
+
|
|
38
|
+
await client.listFunnels({ page: 2, pageSize: 25, status: 'active' });
|
|
39
|
+
|
|
40
|
+
expect(calls[0]).toEqual({
|
|
41
|
+
method: 'get',
|
|
42
|
+
endpoint: '/api/v1/foundry/tenants/acme/funnels/',
|
|
43
|
+
// `pageSize`, NOT `page_size`: StandardPaginatedResponse reads
|
|
44
|
+
// `pageSize`. Transforming the key here would silently page at 25 forever.
|
|
45
|
+
arg: { params: { page: 2, pageSize: 25, status: 'active' } },
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('omits the options object entirely when there are no params', async () => {
|
|
50
|
+
const { http, calls } = spy();
|
|
51
|
+
const client = new FunnelApiClient(funnelHttpAdapter(http), '', foundryTenantFunnelPaths('acme'));
|
|
52
|
+
|
|
53
|
+
await client.getFunnel('f1');
|
|
54
|
+
|
|
55
|
+
expect(calls[0].arg).toBeUndefined();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('passes a write body straight through', async () => {
|
|
59
|
+
const { http, calls } = spy();
|
|
60
|
+
const client = new FunnelApiClient(funnelHttpAdapter(http), '', foundryTenantFunnelPaths('acme'));
|
|
61
|
+
|
|
62
|
+
await client.runFunnel('f1', { triggerType: 'manual' });
|
|
63
|
+
|
|
64
|
+
expect(calls[0].method).toBe('post');
|
|
65
|
+
expect(calls[0].endpoint).toBe('/api/v1/foundry/tenants/acme/funnels/f1/runs/');
|
|
66
|
+
expect(calls[0].arg).toEqual({ triggerType: 'manual' });
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drive FunnelApiClient with an app's OWN http client (bd startsim-em5mn).
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS IS HERE AND NOT IN EACH APP. Every surface that mounts the funnels
|
|
5
|
+
* page already has a configured client — `@startsimpli/api`'s, carrying the
|
|
6
|
+
* bearer token, the 401 bounce to central auth and the snake↔camel key
|
|
7
|
+
* transform. `FetchAdapter` has none of that, so an app wiring the page with it
|
|
8
|
+
* would be signing its own requests. The three-line bridge between the two is
|
|
9
|
+
* the same three lines in foundry-web, the tenant template and market-web, so
|
|
10
|
+
* it lives here once (CLAUDE.md rule 9) — the same shape
|
|
11
|
+
* `createPlanningApi({ http: api.client })` already takes.
|
|
12
|
+
*
|
|
13
|
+
* STRUCTURAL, NOT IMPORTED. The parameter is an interface this module declares
|
|
14
|
+
* rather than `ApiClient` from `@startsimpli/api`, so `@startsimpli/funnels`
|
|
15
|
+
* gains no dependency on it: raise and market install funnels precisely because
|
|
16
|
+
* it is light, and any client with these four methods works.
|
|
17
|
+
*
|
|
18
|
+
* QUERY PARAMS ARE PASSED THROUGH UNTOUCHED, deliberately. The page sends
|
|
19
|
+
* `page` and `pageSize` because that is what the server's paginator reads
|
|
20
|
+
* (`StandardPaginatedResponse.page_size_query_param = 'pageSize'`), and
|
|
21
|
+
* `entity_type` / `stage` / `matched` because those are the filter names the
|
|
22
|
+
* funnels endpoints read. Snake-casing them here would break the first pair and
|
|
23
|
+
* camel-casing them would break the second; the caller already spells each one
|
|
24
|
+
* the way its endpoint wants it.
|
|
25
|
+
*/
|
|
26
|
+
import type { ApiAdapter } from './adapter';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The slice of an http client this adapter needs. `@startsimpli/api`'s
|
|
30
|
+
* `ApiClient` satisfies it as-is.
|
|
31
|
+
*/
|
|
32
|
+
export interface FunnelHttpClient {
|
|
33
|
+
get<T>(endpoint: string, options?: { params?: Record<string, unknown> }): Promise<T>;
|
|
34
|
+
post<T>(endpoint: string, data?: unknown): Promise<T>;
|
|
35
|
+
patch<T>(endpoint: string, data?: unknown): Promise<T>;
|
|
36
|
+
delete<T>(endpoint: string): Promise<T>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Wrap an app's http client as the adapter FunnelApiClient takes. */
|
|
40
|
+
export function funnelHttpAdapter(http: FunnelHttpClient): ApiAdapter {
|
|
41
|
+
return {
|
|
42
|
+
get: <T,>(url: string, params?: Record<string, unknown>) =>
|
|
43
|
+
http.get<T>(url, params ? { params } : undefined),
|
|
44
|
+
post: <T,>(url: string, data: unknown) => http.post<T>(url, data),
|
|
45
|
+
patch: <T,>(url: string, data: unknown) => http.patch<T>(url, data),
|
|
46
|
+
delete: <T,>(url: string) => http.delete<T>(url),
|
|
47
|
+
};
|
|
48
|
+
}
|
package/src/api/index.ts
CHANGED
|
@@ -15,6 +15,22 @@ export { createApiError, isApiError } from './adapter';
|
|
|
15
15
|
export { FetchAdapter } from './default-adapter';
|
|
16
16
|
export type { FetchAdapterConfig } from './default-adapter';
|
|
17
17
|
|
|
18
|
+
// Bridge an app's own configured http client (auth, 401 bounce, key transform)
|
|
19
|
+
// into the adapter this client takes — bd startsim-em5mn.
|
|
20
|
+
export { funnelHttpAdapter } from './http-adapter';
|
|
21
|
+
export type { FunnelHttpClient } from './http-adapter';
|
|
22
|
+
|
|
23
|
+
// Path families — the surfaces the same client can talk to (bd startsim-pxzlf)
|
|
24
|
+
export {
|
|
25
|
+
createFunnelPaths,
|
|
26
|
+
centralFunnelPaths,
|
|
27
|
+
foundryTenantFunnelPaths,
|
|
28
|
+
proxiedCentralFunnelPaths,
|
|
29
|
+
DEFAULT_FUNNEL_PATH_PREFIX,
|
|
30
|
+
DEFAULT_FUNNEL_RUNS_PATH_PREFIX,
|
|
31
|
+
} from './paths';
|
|
32
|
+
export type { FunnelPaths, FunnelPathsOptions } from './paths';
|
|
33
|
+
|
|
18
34
|
// Client
|
|
19
35
|
export { FunnelApiClient } from './client';
|
|
20
36
|
export type {
|