@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 +11 -3
- package/src/api/client.paths.test.ts +20 -7
- package/src/api/client.ts +6 -0
- package/src/api/http-adapter.test.ts +68 -0
- package/src/api/http-adapter.ts +48 -0
- package/src/api/index.ts +5 -0
- package/src/api/paths.test.ts +18 -5
- package/src/api/paths.ts +52 -6
- 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/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/funnels",
|
|
3
|
-
"version": "0.4.
|
|
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/
|
|
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
|
|
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/
|
|
76
|
-
|
|
77
|
-
|
|
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,
|
package/src/api/paths.test.ts
CHANGED
|
@@ -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('
|
|
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.
|
|
116
|
-
|
|
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.
|
|
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) =>
|
|
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
|
-
*
|
|
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:
|
|
131
|
-
|
|
175
|
+
prefix: `${base}/funnels`,
|
|
176
|
+
runsPrefix: `${base}/funnel-runs`,
|
|
177
|
+
triggerOnRunsCollection: true,
|
|
132
178
|
});
|
|
133
179
|
}
|
|
134
180
|
|