@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
@@ -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
+ });
@@ -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 { FieldDefinition } from '../../types';
32
+ import { FieldDefinitionInput } from '../../types';
33
+ import { fieldKey } from '../../types';
33
34
 
34
35
  interface FieldSelectorProps {
35
- fields: FieldDefinition[];
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, FieldDefinition[]>);
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.name} value={field.name}>
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, FieldDefinition } from '../../types';
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: FieldDefinition[] = [
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: FieldDefinition[] = [
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, FieldDefinition, FilterLogic } from '../../types';
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: FieldDefinition[] = [
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: FieldDefinition[] = [
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 fieldPath', () => {
135
+ it('validates empty field', () => {
136
136
  const rule: FilterRule = {
137
- fieldPath: '',
137
+ field: '',
138
138
  operator: 'eq',
139
139
  value: 'test',
140
140
  };
141
141
 
142
142
  const errors = validateFilterRule(rule);
143
- expect(errors).toContain('fieldPath is required');
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, FieldDefinition[]>);
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, FieldDefinition[]>);
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, FieldDefinition } from '../../types';
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
- fieldRegistry: FieldDefinition[];
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, FieldDefinition } from '../../types';
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
+ });