@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.
Files changed (37) hide show
  1. package/README.md +326 -281
  2. package/package.json +24 -16
  3. package/src/api/client.paths.test.ts +175 -0
  4. package/src/api/client.ts +78 -19
  5. package/src/api/http-adapter.test.ts +68 -0
  6. package/src/api/http-adapter.ts +48 -0
  7. package/src/api/index.ts +16 -0
  8. package/src/api/paths.test.ts +138 -0
  9. package/src/api/paths.ts +192 -0
  10. package/src/components/FilterRuleEditor/FieldSelector.tsx +7 -5
  11. package/src/components/FilterRuleEditor/FilterRuleEditor.stories.tsx +3 -3
  12. package/src/components/FilterRuleEditor/FilterRuleEditor.test.tsx +13 -8
  13. package/src/components/FilterRuleEditor/FilterRuleEditor.tsx +7 -2
  14. package/src/components/FilterRuleEditor/OperatorSelector.tsx +1 -1
  15. package/src/components/FilterRuleEditor/RuleRow.test.tsx +166 -0
  16. package/src/components/FilterRuleEditor/RuleRow.tsx +73 -10
  17. package/src/components/FilterRuleEditor/constants.ts +12 -0
  18. package/src/components/FunnelPreview/example.tsx +15 -15
  19. package/src/components/FunnelStageBuilder/FunnelStageBuilder.stories.tsx +2 -2
  20. package/src/components/FunnelStageBuilder/FunnelStageBuilder.test.tsx +2 -2
  21. package/src/components/FunnelStageBuilder/FunnelStageBuilder.tsx +2 -2
  22. package/src/components/FunnelStageBuilder/StageCard.tsx +2 -2
  23. package/src/components/FunnelStageBuilder/StageForm.tsx +2 -2
  24. package/src/core/evaluator.example.ts +23 -23
  25. package/src/core/evaluator.test.ts +4 -1
  26. package/src/core/evaluator.ts +11 -5
  27. package/src/core/operators.ts +35 -0
  28. package/src/page/FunnelsPage.test.tsx +383 -0
  29. package/src/page/FunnelsPage.tsx +739 -0
  30. package/src/page/index.ts +24 -0
  31. package/src/page/types.ts +102 -0
  32. package/src/store/create-funnel-store.ts +1 -0
  33. package/src/stories/demo-data/investors.ts +2 -2
  34. package/src/stories/demo-data/leads.ts +2 -2
  35. package/src/stories/demo-data/recipes.ts +2 -2
  36. package/src/types/contract.test.ts +284 -0
  37. 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.13",
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
- * const client = new FunnelApiClient(adapter, 'https://api.example.com');
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('/api/v1/funnels/'),
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(`/api/v1/funnels/${id}/`));
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('/api/v1/funnels/'), data);
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(`/api/v1/funnels/${id}/`),
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(`/api/v1/funnels/${id}/`));
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(`/api/v1/funnels/${funnelId}/stages/`),
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(`/api/v1/funnels/${funnelId}/stages/${stageId}/`),
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(`/api/v1/funnels/${funnelId}/stages/${stageId}/`)
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(`/api/v1/funnels/${funnelId}/run/`),
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(`/api/v1/funnels/${funnelId}/runs/`),
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(`/api/v1/funnel-runs/${runId}/`));
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(`/api/v1/funnel-runs/${runId}/results/`),
318
- filters
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(`/api/v1/funnel-runs/${runId}/cancel/`),
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(`/api/v1/funnels/${funnelId}/preview/`),
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 {