@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
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the injectable funnel path family (bd startsim-pxzlf).
|
|
3
|
+
*
|
|
4
|
+
* The same client has to talk to four surfaces that differ ONLY in their URL
|
|
5
|
+
* prefix and, for the control plane, in whether a run is addressed globally or
|
|
6
|
+
* under its funnel:
|
|
7
|
+
*
|
|
8
|
+
* central /api/v1/funnels/… + /api/v1/funnel-runs/<id>/
|
|
9
|
+
* control plane /api/v1/foundry/tenants/<slug>/funnels/… (runs nested)
|
|
10
|
+
* tenant proxy /central-api/api/v1/funnels/… (a prefix on the central family)
|
|
11
|
+
* a fork's own whatever the fork mounted
|
|
12
|
+
*
|
|
13
|
+
* Hardcoding '/api/v1/funnels' in the client (api/client.ts before this change)
|
|
14
|
+
* made the first the only one reachable.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, it, expect } from 'vitest';
|
|
18
|
+
import {
|
|
19
|
+
createFunnelPaths,
|
|
20
|
+
centralFunnelPaths,
|
|
21
|
+
foundryTenantFunnelPaths,
|
|
22
|
+
DEFAULT_FUNNEL_PATH_PREFIX,
|
|
23
|
+
} from './paths';
|
|
24
|
+
|
|
25
|
+
describe('centralFunnelPaths', () => {
|
|
26
|
+
it('is the /api/v1/funnels family the client used to hardcode', () => {
|
|
27
|
+
expect(centralFunnelPaths.list()).toBe('/api/v1/funnels/');
|
|
28
|
+
expect(centralFunnelPaths.detail('f1')).toBe('/api/v1/funnels/f1/');
|
|
29
|
+
expect(centralFunnelPaths.stages('f1')).toBe('/api/v1/funnels/f1/stages/');
|
|
30
|
+
expect(centralFunnelPaths.stage('f1', 's1')).toBe('/api/v1/funnels/f1/stages/s1/');
|
|
31
|
+
expect(centralFunnelPaths.run('f1')).toBe('/api/v1/funnels/f1/run/');
|
|
32
|
+
expect(centralFunnelPaths.runs('f1')).toBe('/api/v1/funnels/f1/runs/');
|
|
33
|
+
expect(centralFunnelPaths.preview('f1')).toBe('/api/v1/funnels/f1/preview/');
|
|
34
|
+
expect(centralFunnelPaths.fields()).toBe('/api/v1/funnels/fields/');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('addresses a run globally, because the central API does', () => {
|
|
38
|
+
// config/api_router.py registers funnel-runs as its own router entry.
|
|
39
|
+
expect(centralFunnelPaths.runDetail('r1')).toBe('/api/v1/funnel-runs/r1/');
|
|
40
|
+
expect(centralFunnelPaths.runResults('r1')).toBe('/api/v1/funnel-runs/r1/results/');
|
|
41
|
+
expect(centralFunnelPaths.runCancel('r1')).toBe('/api/v1/funnel-runs/r1/cancel/');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('exposes its prefix so callers can see which surface they are on', () => {
|
|
45
|
+
expect(centralFunnelPaths.prefix).toBe(DEFAULT_FUNNEL_PATH_PREFIX);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('createFunnelPaths', () => {
|
|
50
|
+
it('defaults to the central family', () => {
|
|
51
|
+
const paths = createFunnelPaths();
|
|
52
|
+
expect(paths.list()).toBe(centralFunnelPaths.list());
|
|
53
|
+
expect(paths.runDetail('r1')).toBe(centralFunnelPaths.runDetail('r1'));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('mounts the whole family under a prefix — a tenant proxy', () => {
|
|
57
|
+
const paths = createFunnelPaths({ prefix: '/central-api/api/v1/funnels' });
|
|
58
|
+
expect(paths.list()).toBe('/central-api/api/v1/funnels/');
|
|
59
|
+
expect(paths.detail('f1')).toBe('/central-api/api/v1/funnels/f1/');
|
|
60
|
+
expect(paths.fields()).toBe('/central-api/api/v1/funnels/fields/');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('takes the runs prefix separately, since runs need not live under funnels', () => {
|
|
64
|
+
const paths = createFunnelPaths({
|
|
65
|
+
prefix: '/central-api/api/v1/funnels',
|
|
66
|
+
runsPrefix: '/central-api/api/v1/funnel-runs',
|
|
67
|
+
});
|
|
68
|
+
expect(paths.runDetail('r1')).toBe('/central-api/api/v1/funnel-runs/r1/');
|
|
69
|
+
expect(paths.runCancel('r1')).toBe('/central-api/api/v1/funnel-runs/r1/cancel/');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('nests runs under their funnel when asked (the control-plane shape)', () => {
|
|
73
|
+
const paths = createFunnelPaths({
|
|
74
|
+
prefix: '/api/v1/foundry/tenants/acme/funnels',
|
|
75
|
+
nestRuns: true,
|
|
76
|
+
});
|
|
77
|
+
expect(paths.runDetail('r1', 'f1')).toBe(
|
|
78
|
+
'/api/v1/foundry/tenants/acme/funnels/f1/runs/r1/'
|
|
79
|
+
);
|
|
80
|
+
expect(paths.runResults('r1', 'f1')).toBe(
|
|
81
|
+
'/api/v1/foundry/tenants/acme/funnels/f1/runs/r1/results/'
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('refuses a nested run address with no funnel, instead of building a wrong URL', () => {
|
|
86
|
+
const paths = createFunnelPaths({ prefix: '/api/v1/x/funnels', nestRuns: true });
|
|
87
|
+
expect(() => paths.runDetail('r1')).toThrow(/funnel/i);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('normalizes a prefix given with or without a trailing slash', () => {
|
|
91
|
+
const withSlash = createFunnelPaths({ prefix: '/api/v1/funnels/' });
|
|
92
|
+
const without = createFunnelPaths({ prefix: '/api/v1/funnels' });
|
|
93
|
+
expect(withSlash.list()).toBe(without.list());
|
|
94
|
+
expect(withSlash.detail('f1')).toBe(without.detail('f1'));
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('URL-encodes ids so a slug or an id with a slash cannot escape the family', () => {
|
|
98
|
+
const paths = createFunnelPaths();
|
|
99
|
+
expect(paths.detail('a/b')).toBe('/api/v1/funnels/a%2Fb/');
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('foundryTenantFunnelPaths', () => {
|
|
104
|
+
it('is the control-plane family for one foundry (startsim-9xbz2)', () => {
|
|
105
|
+
const paths = foundryTenantFunnelPaths('acme');
|
|
106
|
+
expect(paths.list()).toBe('/api/v1/foundry/tenants/acme/funnels/');
|
|
107
|
+
expect(paths.detail('f1')).toBe('/api/v1/foundry/tenants/acme/funnels/f1/');
|
|
108
|
+
expect(paths.runs('f1')).toBe('/api/v1/foundry/tenants/acme/funnels/f1/runs/');
|
|
109
|
+
});
|
|
110
|
+
|
|
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.
|
|
115
|
+
const paths = foundryTenantFunnelPaths('acme');
|
|
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/'
|
|
128
|
+
);
|
|
129
|
+
// ...and it does NOT need the funnel id to do it.
|
|
130
|
+
expect(paths.runResults('r1', 'f1')).toBe(paths.runResults('r1'));
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('encodes the slug', () => {
|
|
134
|
+
expect(foundryTenantFunnelPaths('a b').list()).toBe(
|
|
135
|
+
'/api/v1/foundry/tenants/a%20b/funnels/'
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
});
|
package/src/api/paths.ts
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The funnel path family (bd startsim-pxzlf).
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS: FunnelApiClient hardcoded '/api/v1/funnels' in every method,
|
|
5
|
+
* so the one client in the estate could only ever talk to the central API. The
|
|
6
|
+
* same endpoints are mounted on at least four surfaces:
|
|
7
|
+
*
|
|
8
|
+
* central /api/v1/funnels/… runs addressed globally
|
|
9
|
+
* control plane /api/v1/foundry/tenants/<slug>/funnels/… runs nested (startsim-9xbz2)
|
|
10
|
+
* tenant proxy /central-api/api/v1/funnels/… a prefix on the central family
|
|
11
|
+
* a fork's own whatever the fork mounted
|
|
12
|
+
*
|
|
13
|
+
* They differ in exactly two ways: the prefix, and whether a run is addressed
|
|
14
|
+
* on its own or under its funnel. So the family is data — a small object of
|
|
15
|
+
* path builders — and the client takes one.
|
|
16
|
+
*
|
|
17
|
+
* @packageDocumentation
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** The central API's funnel prefix. */
|
|
21
|
+
export const DEFAULT_FUNNEL_PATH_PREFIX = '/api/v1/funnels';
|
|
22
|
+
|
|
23
|
+
/** The central API's run prefix — its own router entry, not nested. */
|
|
24
|
+
export const DEFAULT_FUNNEL_RUNS_PATH_PREFIX = '/api/v1/funnel-runs';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Every URL the funnel client needs, as builders.
|
|
28
|
+
*
|
|
29
|
+
* Run-addressing methods take an optional funnelId because a nested family
|
|
30
|
+
* needs it; on a flat family it is ignored.
|
|
31
|
+
*/
|
|
32
|
+
export interface FunnelPaths {
|
|
33
|
+
/** The prefix this family was built on — for logging and for telling surfaces apart. */
|
|
34
|
+
readonly prefix: string;
|
|
35
|
+
|
|
36
|
+
list(): string;
|
|
37
|
+
detail(funnelId: string): string;
|
|
38
|
+
stages(funnelId: string): string;
|
|
39
|
+
stage(funnelId: string, stageId: string): string;
|
|
40
|
+
preview(funnelId: string): string;
|
|
41
|
+
fields(): string;
|
|
42
|
+
|
|
43
|
+
run(funnelId: string): string;
|
|
44
|
+
runs(funnelId: string): string;
|
|
45
|
+
runDetail(runId: string, funnelId?: string): string;
|
|
46
|
+
runResults(runId: string, funnelId?: string): string;
|
|
47
|
+
runCancel(runId: string, funnelId?: string): string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface FunnelPathsOptions {
|
|
51
|
+
/** Where the funnel collection is mounted. Default: the central API. */
|
|
52
|
+
prefix?: string;
|
|
53
|
+
/**
|
|
54
|
+
* Where runs are mounted when they are addressed on their own.
|
|
55
|
+
* Ignored when `nestRuns` is set. Default: the central API's /funnel-runs.
|
|
56
|
+
*/
|
|
57
|
+
runsPrefix?: string;
|
|
58
|
+
/**
|
|
59
|
+
* Address a run under its funnel (<prefix>/<funnelId>/runs/<runId>/) instead
|
|
60
|
+
* of on its own.
|
|
61
|
+
*/
|
|
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;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function trimSlashes(value: string): string {
|
|
77
|
+
return value.replace(/\/+$/, '');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Encode one path segment: an id or slug must not be able to escape the family. */
|
|
81
|
+
function segment(value: string): string {
|
|
82
|
+
return encodeURIComponent(String(value));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Build a funnel path family.
|
|
87
|
+
*
|
|
88
|
+
* ```ts
|
|
89
|
+
* createFunnelPaths() // central
|
|
90
|
+
* createFunnelPaths({ prefix: '/central-api/api/v1/funnels',
|
|
91
|
+
* runsPrefix: '/central-api/api/v1/funnel-runs' })
|
|
92
|
+
* createFunnelPaths({ prefix: '/api/v1/foundry/tenants/acme/funnels',
|
|
93
|
+
* nestRuns: true })
|
|
94
|
+
* ```
|
|
95
|
+
*/
|
|
96
|
+
export function createFunnelPaths(options: FunnelPathsOptions = {}): FunnelPaths {
|
|
97
|
+
const prefix = trimSlashes(options.prefix ?? DEFAULT_FUNNEL_PATH_PREFIX);
|
|
98
|
+
const runsPrefix = trimSlashes(options.runsPrefix ?? DEFAULT_FUNNEL_RUNS_PATH_PREFIX);
|
|
99
|
+
const nestRuns = options.nestRuns ?? false;
|
|
100
|
+
const triggerOnRunsCollection = options.triggerOnRunsCollection ?? false;
|
|
101
|
+
|
|
102
|
+
function runBase(runId: string, funnelId?: string): string {
|
|
103
|
+
if (!nestRuns) return `${runsPrefix}/${segment(runId)}`;
|
|
104
|
+
if (!funnelId) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`This funnel path family nests runs under their funnel, so addressing run ` +
|
|
107
|
+
`'${runId}' needs a funnelId. Pass it: getFunnelRun(runId, funnelId).`
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return `${prefix}/${segment(funnelId)}/runs/${segment(runId)}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
prefix,
|
|
115
|
+
|
|
116
|
+
list: () => `${prefix}/`,
|
|
117
|
+
detail: (funnelId) => `${prefix}/${segment(funnelId)}/`,
|
|
118
|
+
stages: (funnelId) => `${prefix}/${segment(funnelId)}/stages/`,
|
|
119
|
+
stage: (funnelId, stageId) => `${prefix}/${segment(funnelId)}/stages/${segment(stageId)}/`,
|
|
120
|
+
preview: (funnelId) => `${prefix}/${segment(funnelId)}/preview/`,
|
|
121
|
+
// The registry endpoint lives beside the collection on every surface
|
|
122
|
+
// (config/api_router.py: path("funnels/fields/", FunnelFieldsView…)).
|
|
123
|
+
fields: () => `${prefix}/fields/`,
|
|
124
|
+
|
|
125
|
+
run: (funnelId) =>
|
|
126
|
+
triggerOnRunsCollection
|
|
127
|
+
? `${prefix}/${segment(funnelId)}/runs/`
|
|
128
|
+
: `${prefix}/${segment(funnelId)}/run/`,
|
|
129
|
+
runs: (funnelId) => `${prefix}/${segment(funnelId)}/runs/`,
|
|
130
|
+
runDetail: (runId, funnelId) => `${runBase(runId, funnelId)}/`,
|
|
131
|
+
runResults: (runId, funnelId) => `${runBase(runId, funnelId)}/results/`,
|
|
132
|
+
runCancel: (runId, funnelId) => `${runBase(runId, funnelId)}/cancel/`,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The central API family: what the client used to hardcode. */
|
|
137
|
+
export const centralFunnelPaths: FunnelPaths = createFunnelPaths();
|
|
138
|
+
|
|
139
|
+
/**
|
|
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.
|
|
171
|
+
*/
|
|
172
|
+
export function foundryTenantFunnelPaths(slug: string): FunnelPaths {
|
|
173
|
+
const base = `/api/v1/foundry/tenants/${segment(slug)}`;
|
|
174
|
+
return createFunnelPaths({
|
|
175
|
+
prefix: `${base}/funnels`,
|
|
176
|
+
runsPrefix: `${base}/funnel-runs`,
|
|
177
|
+
triggerOnRunsCollection: true,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The same central family reached through a fork's same-origin proxy, e.g.
|
|
183
|
+
* '/central-api'. A tenant page cannot call the central API cross-origin
|
|
184
|
+
* (project_foreign_apex_sso_same_origin_proxy), so it proxies it.
|
|
185
|
+
*/
|
|
186
|
+
export function proxiedCentralFunnelPaths(proxyPrefix: string): FunnelPaths {
|
|
187
|
+
const base = trimSlashes(proxyPrefix);
|
|
188
|
+
return createFunnelPaths({
|
|
189
|
+
prefix: `${base}${DEFAULT_FUNNEL_PATH_PREFIX}`,
|
|
190
|
+
runsPrefix: `${base}${DEFAULT_FUNNEL_RUNS_PATH_PREFIX}`,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
@@ -29,10 +29,12 @@
|
|
|
29
29
|
* - Focus visible on keyboard navigation
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
|
-
import {
|
|
32
|
+
import { FieldDefinitionInput } from '../../types';
|
|
33
|
+
import { fieldKey } from '../../types';
|
|
33
34
|
|
|
34
35
|
interface FieldSelectorProps {
|
|
35
|
-
fields
|
|
36
|
+
/** Canonical /fields/ entries or legacy {name,label,type,operators} ones. */
|
|
37
|
+
fields: FieldDefinitionInput[];
|
|
36
38
|
value: string;
|
|
37
39
|
onChange: (fieldName: string) => void;
|
|
38
40
|
error?: string;
|
|
@@ -54,7 +56,7 @@ export function FieldSelector({
|
|
|
54
56
|
}
|
|
55
57
|
acc[category].push(field);
|
|
56
58
|
return acc;
|
|
57
|
-
}, {} as Record<string,
|
|
59
|
+
}, {} as Record<string, FieldDefinitionInput[]>);
|
|
58
60
|
|
|
59
61
|
const categories = Object.keys(grouped).sort();
|
|
60
62
|
|
|
@@ -78,8 +80,8 @@ export function FieldSelector({
|
|
|
78
80
|
{categories.map((category) => (
|
|
79
81
|
<optgroup key={category} label={category}>
|
|
80
82
|
{grouped[category].map((field) => (
|
|
81
|
-
<option key={field
|
|
82
|
-
{field.label}
|
|
83
|
+
<option key={fieldKey(field)} value={fieldKey(field)}>
|
|
84
|
+
{field.label ?? fieldKey(field)}
|
|
83
85
|
</option>
|
|
84
86
|
))}
|
|
85
87
|
</optgroup>
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { useState } from 'react';
|
|
8
8
|
import { FilterRuleEditor } from './FilterRuleEditor';
|
|
9
|
-
import { FilterRule, FilterLogic,
|
|
9
|
+
import { FilterRule, FilterLogic, FieldDefinitionInput } from '../../types';
|
|
10
10
|
|
|
11
11
|
export default {
|
|
12
12
|
title: 'Components/FilterRuleEditor',
|
|
@@ -17,7 +17,7 @@ export default {
|
|
|
17
17
|
// Investor Field Registry
|
|
18
18
|
// ============================================================================
|
|
19
19
|
|
|
20
|
-
const investorFields:
|
|
20
|
+
const investorFields: FieldDefinitionInput[] = [
|
|
21
21
|
{
|
|
22
22
|
name: 'name',
|
|
23
23
|
label: 'Name',
|
|
@@ -101,7 +101,7 @@ const investorFields: FieldDefinition[] = [
|
|
|
101
101
|
// Recipe Field Registry
|
|
102
102
|
// ============================================================================
|
|
103
103
|
|
|
104
|
-
const recipeFields:
|
|
104
|
+
const recipeFields: FieldDefinitionInput[] = [
|
|
105
105
|
{
|
|
106
106
|
name: 'title',
|
|
107
107
|
label: 'Recipe Title',
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { describe, it, expect, vi } from 'vitest';
|
|
8
|
-
import { FilterRule,
|
|
8
|
+
import { FilterRule, FieldDefinitionInput, FilterLogic } from '../../types';
|
|
9
9
|
import { validateFilterRule } from '../../types';
|
|
10
10
|
|
|
11
11
|
// ============================================================================
|
|
@@ -15,7 +15,7 @@ import { validateFilterRule } from '../../types';
|
|
|
15
15
|
/**
|
|
16
16
|
* Investor field registry
|
|
17
17
|
*/
|
|
18
|
-
const investorFieldRegistry:
|
|
18
|
+
const investorFieldRegistry: FieldDefinitionInput[] = [
|
|
19
19
|
{
|
|
20
20
|
name: 'name',
|
|
21
21
|
label: 'Name',
|
|
@@ -73,7 +73,7 @@ const investorFieldRegistry: FieldDefinition[] = [
|
|
|
73
73
|
/**
|
|
74
74
|
* Recipe field registry - completely different domain!
|
|
75
75
|
*/
|
|
76
|
-
const recipeFieldRegistry:
|
|
76
|
+
const recipeFieldRegistry: FieldDefinitionInput[] = [
|
|
77
77
|
{
|
|
78
78
|
name: 'title',
|
|
79
79
|
label: 'Recipe Title',
|
|
@@ -132,15 +132,20 @@ const recipeFieldRegistry: FieldDefinition[] = [
|
|
|
132
132
|
// ============================================================================
|
|
133
133
|
|
|
134
134
|
describe('FilterRuleEditor - Generic Validation', () => {
|
|
135
|
-
it('validates empty
|
|
135
|
+
it('validates empty field', () => {
|
|
136
136
|
const rule: FilterRule = {
|
|
137
|
-
|
|
137
|
+
field: '',
|
|
138
138
|
operator: 'eq',
|
|
139
139
|
value: 'test',
|
|
140
140
|
};
|
|
141
141
|
|
|
142
142
|
const errors = validateFilterRule(rule);
|
|
143
|
-
expect(errors).toContain('
|
|
143
|
+
expect(errors).toContain('field is required');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('still accepts a rule that names its field the deprecated way', () => {
|
|
147
|
+
const legacy = { fieldPath: 'name', operator: 'eq', value: 'test' } as FilterRule;
|
|
148
|
+
expect(validateFilterRule(legacy)).toEqual([]);
|
|
144
149
|
});
|
|
145
150
|
|
|
146
151
|
it('validates missing operator', () => {
|
|
@@ -414,7 +419,7 @@ describe('FilterRuleEditor - Field Registry Grouping', () => {
|
|
|
414
419
|
}
|
|
415
420
|
acc[category].push(field);
|
|
416
421
|
return acc;
|
|
417
|
-
}, {} as Record<string,
|
|
422
|
+
}, {} as Record<string, FieldDefinitionInput[]>);
|
|
418
423
|
|
|
419
424
|
expect(Object.keys(grouped)).toContain('Profile');
|
|
420
425
|
expect(Object.keys(grouped)).toContain('Investment');
|
|
@@ -431,7 +436,7 @@ describe('FilterRuleEditor - Field Registry Grouping', () => {
|
|
|
431
436
|
}
|
|
432
437
|
acc[category].push(field);
|
|
433
438
|
return acc;
|
|
434
|
-
}, {} as Record<string,
|
|
439
|
+
}, {} as Record<string, FieldDefinitionInput[]>);
|
|
435
440
|
|
|
436
441
|
expect(Object.keys(grouped)).toContain('Basic Info');
|
|
437
442
|
expect(Object.keys(grouped)).toContain('Classification');
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
* - Transitions: transition-all, duration-200
|
|
51
51
|
*/
|
|
52
52
|
|
|
53
|
-
import { FilterRule, FilterLogic,
|
|
53
|
+
import { FilterRule, FilterLogic, FieldDefinitionInput } from '../../types';
|
|
54
54
|
import { LogicToggle } from './LogicToggle';
|
|
55
55
|
import { RuleRow } from './RuleRow';
|
|
56
56
|
import { validateFilterRule } from '../../types';
|
|
@@ -63,7 +63,8 @@ export interface FilterRuleEditorProps {
|
|
|
63
63
|
onChange: (rules: FilterRule[]) => void;
|
|
64
64
|
|
|
65
65
|
/** Field registry for the entity type */
|
|
66
|
-
|
|
66
|
+
/** Canonical /fields/ entries or legacy {name,label,type,operators} ones. */
|
|
67
|
+
fieldRegistry: FieldDefinitionInput[];
|
|
67
68
|
|
|
68
69
|
/** Filter logic (AND/OR) */
|
|
69
70
|
logic?: FilterLogic;
|
|
@@ -86,6 +87,10 @@ export function FilterRuleEditor({
|
|
|
86
87
|
// Add a new empty rule
|
|
87
88
|
const handleAddRule = () => {
|
|
88
89
|
const newRule: FilterRule = {
|
|
90
|
+
field: '',
|
|
91
|
+
// Both keys start empty: RuleRow decides which one carries the chosen
|
|
92
|
+
// name, depending on whether the registry entry is a resolver key or a
|
|
93
|
+
// hand-written one (see its handleFieldChange).
|
|
89
94
|
fieldPath: '',
|
|
90
95
|
operator: 'eq' as any,
|
|
91
96
|
value: null,
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* - Focus visible on keyboard navigation
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
-
import { Operator,
|
|
27
|
+
import { Operator, FieldDefinitionInput } from '../../types';
|
|
28
28
|
import { OPERATOR_LABELS } from './constants';
|
|
29
29
|
|
|
30
30
|
interface OperatorSelectorProps {
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RuleRow renders BOTH field-registry shapes (bd startsim-pxzlf).
|
|
3
|
+
*
|
|
4
|
+
* The canonical registry is the backend's /fields/ payload
|
|
5
|
+
* ({key,label,category,valueType,enumValues,allowedOperators}). The registries
|
|
6
|
+
* that predate it are hand-written arrays of {name,label,type,operators} —
|
|
7
|
+
* market-web's LEAD_FIELDS is one, and it is live. Both have to render, or
|
|
8
|
+
* "one contract" is just a rename that breaks a shipped app.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
12
|
+
import { render, screen } from '@testing-library/react';
|
|
13
|
+
import { RuleRow } from './RuleRow';
|
|
14
|
+
import type { FieldDefinitionInput, FilterRule } from '../../types';
|
|
15
|
+
|
|
16
|
+
const canonicalRegistry: FieldDefinitionInput[] = [
|
|
17
|
+
{
|
|
18
|
+
key: 'tag.stage_focus',
|
|
19
|
+
label: 'stage_focus',
|
|
20
|
+
category: 'Tags',
|
|
21
|
+
valueType: 'enum',
|
|
22
|
+
enumValues: ['pre_seed', 'seed'],
|
|
23
|
+
allowedOperators: ['eq', 'ne', 'exists', 'not_exists'],
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
key: 'metric.financial.check_size_min',
|
|
27
|
+
label: 'check_size_min',
|
|
28
|
+
category: 'Financial',
|
|
29
|
+
valueType: 'number',
|
|
30
|
+
allowedOperators: ['gte', 'lte', 'between'],
|
|
31
|
+
},
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const legacyRegistry: FieldDefinitionInput[] = [
|
|
35
|
+
{
|
|
36
|
+
name: 'contact__name',
|
|
37
|
+
label: 'Name',
|
|
38
|
+
type: 'string',
|
|
39
|
+
category: 'Profile',
|
|
40
|
+
operators: ['eq', 'contains'],
|
|
41
|
+
},
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
function noop() {}
|
|
45
|
+
|
|
46
|
+
describe('RuleRow with the canonical registry', () => {
|
|
47
|
+
it('selects the field the rule names with `field`', () => {
|
|
48
|
+
const rule: FilterRule = { field: 'tag.stage_focus', operator: 'eq', value: 'seed' };
|
|
49
|
+
render(
|
|
50
|
+
<RuleRow rule={rule} onChange={noop} onRemove={noop} fieldRegistry={canonicalRegistry} />
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const fieldSelect = screen.getByLabelText('Field') as HTMLSelectElement;
|
|
54
|
+
expect(fieldSelect.value).toBe('tag.stage_focus');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('offers the operators the registry allows, including the backend-only ones', () => {
|
|
58
|
+
const rule: FilterRule = {
|
|
59
|
+
field: 'metric.financial.check_size_min',
|
|
60
|
+
operator: 'gte',
|
|
61
|
+
value: 1,
|
|
62
|
+
};
|
|
63
|
+
render(
|
|
64
|
+
<RuleRow rule={rule} onChange={noop} onRemove={noop} fieldRegistry={canonicalRegistry} />
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const operatorSelect = screen.getByLabelText('Operator') as HTMLSelectElement;
|
|
68
|
+
const offered = Array.from(operatorSelect.options).map((o) => o.value);
|
|
69
|
+
expect(offered).toEqual(expect.arrayContaining(['gte', 'lte', 'between']));
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('renders an enum field as a picker over its enum values', () => {
|
|
73
|
+
const rule: FilterRule = { field: 'tag.stage_focus', operator: 'eq', value: 'seed' };
|
|
74
|
+
render(
|
|
75
|
+
<RuleRow rule={rule} onChange={noop} onRemove={noop} fieldRegistry={canonicalRegistry} />
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
// The value input is a select over the tag category's known values.
|
|
79
|
+
const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
|
|
80
|
+
const values = selects.flatMap((s) => Array.from(s.options).map((o) => o.value));
|
|
81
|
+
expect(values).toEqual(expect.arrayContaining(['pre_seed', 'seed']));
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('writes the canonical key on the rule when the field changes', () => {
|
|
85
|
+
const onChange = vi.fn();
|
|
86
|
+
const rule: FilterRule = { field: '', operator: 'eq', value: null };
|
|
87
|
+
render(
|
|
88
|
+
<RuleRow
|
|
89
|
+
rule={rule}
|
|
90
|
+
onChange={onChange}
|
|
91
|
+
onRemove={noop}
|
|
92
|
+
fieldRegistry={canonicalRegistry}
|
|
93
|
+
/>
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const fieldSelect = screen.getByLabelText('Field') as HTMLSelectElement;
|
|
97
|
+
fieldSelect.value = 'tag.stage_focus';
|
|
98
|
+
fieldSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
|
99
|
+
|
|
100
|
+
expect(onChange).toHaveBeenCalledWith(
|
|
101
|
+
expect.objectContaining({ field: 'tag.stage_focus', operator: 'eq' })
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('RuleRow with a hand-written registry that predates /fields/', () => {
|
|
107
|
+
it('does NOT write a non-registry name into `field` — that would be a 400', () => {
|
|
108
|
+
// 'contact__name' is not a resolver key. The serializer's strict branch
|
|
109
|
+
// fires on any truthy `field` and rejects an unknown one, so a picker
|
|
110
|
+
// driven by a hand-written registry has to keep taking the legacy branch:
|
|
111
|
+
// `field` empty, the name on `fieldPath`.
|
|
112
|
+
const onChange = vi.fn();
|
|
113
|
+
const rule = { field: '', operator: 'eq', value: null } as FilterRule;
|
|
114
|
+
render(
|
|
115
|
+
<RuleRow rule={rule} onChange={onChange} onRemove={noop} fieldRegistry={legacyRegistry} />
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const fieldSelect = screen.getByLabelText('Field') as HTMLSelectElement;
|
|
119
|
+
fieldSelect.value = 'contact__name';
|
|
120
|
+
fieldSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
|
121
|
+
|
|
122
|
+
expect(onChange).toHaveBeenCalledWith(
|
|
123
|
+
expect.objectContaining({ field: '', fieldPath: 'contact__name', operator: 'eq' })
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('resolves a row the backend auto-migrated, which carries BOTH keys', () => {
|
|
128
|
+
// The serializer's legacy branch sets attrs['field'] and does NOT clear
|
|
129
|
+
// field_path, and _create_rule_tree persists the whole validated dict — so
|
|
130
|
+
// every rule market has saved since that change round-trips as
|
|
131
|
+
// {field: 'contact.name', fieldPath: 'contact__name'}. Preferring `field`
|
|
132
|
+
// alone would leave the picker blank against a registry keyed the old way,
|
|
133
|
+
// and re-saving that blank row would drop the rule.
|
|
134
|
+
const migrated = {
|
|
135
|
+
field: 'contact.name',
|
|
136
|
+
fieldPath: 'contact__name',
|
|
137
|
+
operator: 'contains',
|
|
138
|
+
value: 'Ada',
|
|
139
|
+
} as FilterRule;
|
|
140
|
+
render(
|
|
141
|
+
<RuleRow rule={migrated} onChange={noop} onRemove={noop} fieldRegistry={legacyRegistry} />
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const fieldSelect = screen.getByLabelText('Field') as HTMLSelectElement;
|
|
145
|
+
expect(fieldSelect.value).toBe('contact__name');
|
|
146
|
+
|
|
147
|
+
const operatorSelect = screen.getByLabelText('Operator') as HTMLSelectElement;
|
|
148
|
+
expect(operatorSelect.disabled).toBe(false);
|
|
149
|
+
expect(Array.from(operatorSelect.options).map((o) => o.value)).toEqual(
|
|
150
|
+
expect.arrayContaining(['eq', 'contains'])
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('still resolves a rule that names its field the deprecated way', () => {
|
|
155
|
+
const rule = { fieldPath: 'contact__name', operator: 'contains', value: 'Ada' } as FilterRule;
|
|
156
|
+
render(<RuleRow rule={rule} onChange={noop} onRemove={noop} fieldRegistry={legacyRegistry} />);
|
|
157
|
+
|
|
158
|
+
const fieldSelect = screen.getByLabelText('Field') as HTMLSelectElement;
|
|
159
|
+
expect(fieldSelect.value).toBe('contact__name');
|
|
160
|
+
|
|
161
|
+
const operatorSelect = screen.getByLabelText('Operator') as HTMLSelectElement;
|
|
162
|
+
expect(Array.from(operatorSelect.options).map((o) => o.value)).toEqual(
|
|
163
|
+
expect.arrayContaining(['eq', 'contains'])
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
});
|