@startsimpli/funnels 0.4.14 → 0.4.16

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 CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@startsimpli/funnels",
3
- "version": "0.4.14",
3
+ "version": "0.4.16",
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",
@@ -48,7 +49,8 @@
48
49
  "peerDependencies": {
49
50
  "react": "^18.0.0 || ^19.0.0",
50
51
  "react-dom": "^18.0.0 || ^19.0.0",
51
- "zustand": "^4.0.0 || ^5.0.0"
52
+ "zustand": "^4.0.0 || ^5.0.0",
53
+ "@startsimpli/ui": "^0.4.125"
52
54
  },
53
55
  "devDependencies": {
54
56
  "@chromatic-com/storybook": "^5.1.2",
@@ -75,7 +77,8 @@
75
77
  "tsup": "^8.5.1",
76
78
  "typescript": "^6.0.3",
77
79
  "vitest": "^4.1.5",
78
- "zustand": "^5.0.12"
80
+ "zustand": "^5.0.12",
81
+ "@startsimpli/ui": "0.4.125"
79
82
  },
80
83
  "dependencies": {
81
84
  "@dnd-kit/core": "^6.3.1",
@@ -83,6 +86,11 @@
83
86
  "@dnd-kit/utilities": "^3.2.2",
84
87
  "@xyflow/react": "^12.10.2"
85
88
  },
89
+ "peerDependenciesMeta": {
90
+ "@startsimpli/ui": {
91
+ "optional": true
92
+ }
93
+ },
86
94
  "scripts": {
87
95
  "build": "tsup",
88
96
  "dev": "tsup --watch",
@@ -60,21 +60,34 @@ describe('FunnelApiClient path injection', () => {
60
60
  await client.getFunnel('f1');
61
61
  expect(adapter.lastUrl).toBe('/api/v1/foundry/tenants/acme/funnels/f1/');
62
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.
63
65
  await client.runFunnel('f1');
64
- expect(adapter.lastUrl).toBe('/api/v1/foundry/tenants/acme/funnels/f1/run/');
66
+ expect(adapter.lastUrl).toBe('/api/v1/foundry/tenants/acme/funnels/f1/runs/');
65
67
 
66
68
  await client.getFunnelRuns('f1');
67
69
  expect(adapter.lastUrl).toBe('/api/v1/foundry/tenants/acme/funnels/f1/runs/');
68
70
  });
69
71
 
70
- it('addresses a nested run through its funnel on the control plane', async () => {
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).
71
78
  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
79
  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/');
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 });
78
91
  });
79
92
 
80
93
  it('talks through a tenant proxy prefix', async () => {
package/src/api/client.ts CHANGED
@@ -326,6 +326,12 @@ export class FunnelApiClient {
326
326
  runId: string,
327
327
  filters?: {
328
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;
329
335
  page?: number;
330
336
  pageSize?: number;
331
337
  /** Required only on a family that nests runs under their funnel. */
@@ -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,11 @@ 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
+
18
23
  // Path families — the surfaces the same client can talk to (bd startsim-pxzlf)
19
24
  export {
20
25
  createFunnelPaths,
@@ -105,16 +105,29 @@ describe('foundryTenantFunnelPaths', () => {
105
105
  const paths = foundryTenantFunnelPaths('acme');
106
106
  expect(paths.list()).toBe('/api/v1/foundry/tenants/acme/funnels/');
107
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
108
  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
109
  });
112
110
 
113
- it('nests runs under the funnel, which is how the nested viewset addresses them', () => {
111
+ it('triggers a run by POSTing to the runs COLLECTION, not to a run/ action', () => {
112
+ // FoundryViewSet mounts ONE `funnel_runs` action: GET lists, POST triggers.
113
+ // `funnels/<id>/run/` reaches no route there (verified against prod
114
+ // 2026-09-18: Django's 404 HTML, not a DRF body). bd startsim-em5mn.
114
115
  const paths = foundryTenantFunnelPaths('acme');
115
- expect(paths.runDetail('r1', 'f1')).toBe(
116
- '/api/v1/foundry/tenants/acme/funnels/f1/runs/r1/'
116
+ expect(paths.run('f1')).toBe('/api/v1/foundry/tenants/acme/funnels/f1/runs/');
117
+ expect(paths.run('f1')).toBe(paths.runs('f1'));
118
+ });
119
+
120
+ it('addresses a run on the sibling funnel-runs collection, not under its funnel', () => {
121
+ // The action is `funnel-runs/(?P<run_id>[^/.]+)/results` — a sibling of
122
+ // `funnels/`, because `runs/` on that viewset is already the foundry's own
123
+ // build history. The old `nestRuns: true` built
124
+ // `funnels/f1/runs/r1/results/`, which has never resolved.
125
+ const paths = foundryTenantFunnelPaths('acme');
126
+ expect(paths.runResults('r1')).toBe(
127
+ '/api/v1/foundry/tenants/acme/funnel-runs/r1/results/'
117
128
  );
129
+ // ...and it does NOT need the funnel id to do it.
130
+ expect(paths.runResults('r1', 'f1')).toBe(paths.runResults('r1'));
118
131
  });
119
132
 
120
133
  it('encodes the slug', () => {
package/src/api/paths.ts CHANGED
@@ -57,9 +57,20 @@ export interface FunnelPathsOptions {
57
57
  runsPrefix?: string;
58
58
  /**
59
59
  * Address a run under its funnel (<prefix>/<funnelId>/runs/<runId>/) instead
60
- * of on its own. The control-plane viewset nests them this way.
60
+ * of on its own.
61
61
  */
62
62
  nestRuns?: boolean;
63
+ /**
64
+ * Trigger a run by POSTing to the run COLLECTION (<prefix>/<id>/runs/)
65
+ * rather than to a separate <prefix>/<id>/run/ action.
66
+ *
67
+ * The control plane is built this way — one `funnel_runs` action where GET
68
+ * lists and POST triggers — while the central API keeps `run/` and `runs/`
69
+ * as two endpoints. Verified against prod 2026-09-18: a POST to the control
70
+ * plane's `funnels/<id>/run/` reaches no route at all (Django 404 HTML,
71
+ * not a DRF body), whereas `funnels/<id>/runs/` answers.
72
+ */
73
+ triggerOnRunsCollection?: boolean;
63
74
  }
64
75
 
65
76
  function trimSlashes(value: string): string {
@@ -86,6 +97,7 @@ export function createFunnelPaths(options: FunnelPathsOptions = {}): FunnelPaths
86
97
  const prefix = trimSlashes(options.prefix ?? DEFAULT_FUNNEL_PATH_PREFIX);
87
98
  const runsPrefix = trimSlashes(options.runsPrefix ?? DEFAULT_FUNNEL_RUNS_PATH_PREFIX);
88
99
  const nestRuns = options.nestRuns ?? false;
100
+ const triggerOnRunsCollection = options.triggerOnRunsCollection ?? false;
89
101
 
90
102
  function runBase(runId: string, funnelId?: string): string {
91
103
  if (!nestRuns) return `${runsPrefix}/${segment(runId)}`;
@@ -110,7 +122,10 @@ export function createFunnelPaths(options: FunnelPathsOptions = {}): FunnelPaths
110
122
  // (config/api_router.py: path("funnels/fields/", FunnelFieldsView…)).
111
123
  fields: () => `${prefix}/fields/`,
112
124
 
113
- run: (funnelId) => `${prefix}/${segment(funnelId)}/run/`,
125
+ run: (funnelId) =>
126
+ triggerOnRunsCollection
127
+ ? `${prefix}/${segment(funnelId)}/runs/`
128
+ : `${prefix}/${segment(funnelId)}/run/`,
114
129
  runs: (funnelId) => `${prefix}/${segment(funnelId)}/runs/`,
115
130
  runDetail: (runId, funnelId) => `${runBase(runId, funnelId)}/`,
116
131
  runResults: (runId, funnelId) => `${runBase(runId, funnelId)}/results/`,
@@ -122,13 +137,44 @@ export function createFunnelPaths(options: FunnelPathsOptions = {}): FunnelPaths
122
137
  export const centralFunnelPaths: FunnelPaths = createFunnelPaths();
123
138
 
124
139
  /**
125
- * The control-plane family for one foundry (startsim-9xbz2):
126
- * /api/v1/foundry/tenants/<slug>/funnels/… with runs nested under their funnel.
140
+ * The control-plane family for one foundry (startsim-9xbz2).
141
+ *
142
+ * CORRECTED against the routes FoundryViewSet actually mounts (bd
143
+ * startsim-em5mn). Every URL below was exercised against prod on 2026-09-18 as
144
+ * the foundry QA owner; the ones marked ✗ answered with Django's 404 HTML page,
145
+ * which is what "this path reaches no route at all" looks like, as opposed to
146
+ * the DRF `{"error": …}` body a real route returns for a missing row.
147
+ *
148
+ * ✓ funnels/ GET list, POST create
149
+ * ✓ funnels/<id>/ GET, PATCH, DELETE
150
+ * ✓ funnels/<id>/runs/ GET history, POST trigger (202)
151
+ * ✓ funnel-runs/<runId>/results/ GET, paged, ?matched= ?stage=
152
+ * ✗ funnels/<id>/run/ — trigger is a POST to `runs/`
153
+ * ✗ funnels/<fid>/runs/<rid>/… — results hang off `funnel-runs/`, a
154
+ * SIBLING of `funnels/`, not under it
155
+ * ✗ funnels/<id>/stages/ — stages are written nested in the funnel
156
+ * ✗ funnels/<id>/preview/ — no preview action exists here
157
+ *
158
+ * The previous definition passed `nestRuns: true`, which built
159
+ * `funnels/<fid>/runs/<rid>/results/` — a path that has never resolved. It had
160
+ * no runtime consumer, so nothing was broken by it and nothing is broken by
161
+ * this correction; it was simply never driven by a page until now.
162
+ *
163
+ * `fields()` REMAINS A LIE ON THIS SURFACE and is the one thing this function
164
+ * cannot fix. `funnels/fields/` matches the `funnels/(?P<funnel_id>[^/.]+)`
165
+ * action first (DRF orders extra actions by method name, and `funnel_detail`
166
+ * sorts before any `funnel_fields` would), so it 404s with "funnel not found".
167
+ * The vocabulary is only served from central `/api/v1/funnels/fields/`, scoped
168
+ * to the CALLER's company. That is why FunnelsPage takes its fields client as a
169
+ * separate injection instead of reading `client.paths.fields()` — see
170
+ * `src/page/FunnelsPage.tsx`. bd startsim-em5mn.1 tracks mounting the route.
127
171
  */
128
172
  export function foundryTenantFunnelPaths(slug: string): FunnelPaths {
173
+ const base = `/api/v1/foundry/tenants/${segment(slug)}`;
129
174
  return createFunnelPaths({
130
- prefix: `/api/v1/foundry/tenants/${segment(slug)}/funnels`,
131
- nestRuns: true,
175
+ prefix: `${base}/funnels`,
176
+ runsPrefix: `${base}/funnel-runs`,
177
+ triggerOnRunsCollection: true,
132
178
  });
133
179
  }
134
180