@startsimpli/funnels 0.4.13 → 0.4.14

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 (31) hide show
  1. package/README.md +326 -281
  2. package/package.json +14 -14
  3. package/src/api/client.paths.test.ts +162 -0
  4. package/src/api/client.ts +72 -19
  5. package/src/api/index.ts +11 -0
  6. package/src/api/paths.test.ts +125 -0
  7. package/src/api/paths.ts +146 -0
  8. package/src/components/FilterRuleEditor/FieldSelector.tsx +7 -5
  9. package/src/components/FilterRuleEditor/FilterRuleEditor.stories.tsx +3 -3
  10. package/src/components/FilterRuleEditor/FilterRuleEditor.test.tsx +13 -8
  11. package/src/components/FilterRuleEditor/FilterRuleEditor.tsx +7 -2
  12. package/src/components/FilterRuleEditor/OperatorSelector.tsx +1 -1
  13. package/src/components/FilterRuleEditor/RuleRow.test.tsx +166 -0
  14. package/src/components/FilterRuleEditor/RuleRow.tsx +73 -10
  15. package/src/components/FilterRuleEditor/constants.ts +12 -0
  16. package/src/components/FunnelPreview/example.tsx +15 -15
  17. package/src/components/FunnelStageBuilder/FunnelStageBuilder.stories.tsx +2 -2
  18. package/src/components/FunnelStageBuilder/FunnelStageBuilder.test.tsx +2 -2
  19. package/src/components/FunnelStageBuilder/FunnelStageBuilder.tsx +2 -2
  20. package/src/components/FunnelStageBuilder/StageCard.tsx +2 -2
  21. package/src/components/FunnelStageBuilder/StageForm.tsx +2 -2
  22. package/src/core/evaluator.example.ts +23 -23
  23. package/src/core/evaluator.test.ts +4 -1
  24. package/src/core/evaluator.ts +11 -5
  25. package/src/core/operators.ts +35 -0
  26. package/src/store/create-funnel-store.ts +1 -0
  27. package/src/stories/demo-data/investors.ts +2 -2
  28. package/src/stories/demo-data/leads.ts +2 -2
  29. package/src/stories/demo-data/recipes.ts +2 -2
  30. package/src/types/contract.test.ts +284 -0
  31. package/src/types/index.ts +339 -49
@@ -0,0 +1,146 @@
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. The control-plane viewset nests them this way.
61
+ */
62
+ nestRuns?: boolean;
63
+ }
64
+
65
+ function trimSlashes(value: string): string {
66
+ return value.replace(/\/+$/, '');
67
+ }
68
+
69
+ /** Encode one path segment: an id or slug must not be able to escape the family. */
70
+ function segment(value: string): string {
71
+ return encodeURIComponent(String(value));
72
+ }
73
+
74
+ /**
75
+ * Build a funnel path family.
76
+ *
77
+ * ```ts
78
+ * createFunnelPaths() // central
79
+ * createFunnelPaths({ prefix: '/central-api/api/v1/funnels',
80
+ * runsPrefix: '/central-api/api/v1/funnel-runs' })
81
+ * createFunnelPaths({ prefix: '/api/v1/foundry/tenants/acme/funnels',
82
+ * nestRuns: true })
83
+ * ```
84
+ */
85
+ export function createFunnelPaths(options: FunnelPathsOptions = {}): FunnelPaths {
86
+ const prefix = trimSlashes(options.prefix ?? DEFAULT_FUNNEL_PATH_PREFIX);
87
+ const runsPrefix = trimSlashes(options.runsPrefix ?? DEFAULT_FUNNEL_RUNS_PATH_PREFIX);
88
+ const nestRuns = options.nestRuns ?? false;
89
+
90
+ function runBase(runId: string, funnelId?: string): string {
91
+ if (!nestRuns) return `${runsPrefix}/${segment(runId)}`;
92
+ if (!funnelId) {
93
+ throw new Error(
94
+ `This funnel path family nests runs under their funnel, so addressing run ` +
95
+ `'${runId}' needs a funnelId. Pass it: getFunnelRun(runId, funnelId).`
96
+ );
97
+ }
98
+ return `${prefix}/${segment(funnelId)}/runs/${segment(runId)}`;
99
+ }
100
+
101
+ return {
102
+ prefix,
103
+
104
+ list: () => `${prefix}/`,
105
+ detail: (funnelId) => `${prefix}/${segment(funnelId)}/`,
106
+ stages: (funnelId) => `${prefix}/${segment(funnelId)}/stages/`,
107
+ stage: (funnelId, stageId) => `${prefix}/${segment(funnelId)}/stages/${segment(stageId)}/`,
108
+ preview: (funnelId) => `${prefix}/${segment(funnelId)}/preview/`,
109
+ // The registry endpoint lives beside the collection on every surface
110
+ // (config/api_router.py: path("funnels/fields/", FunnelFieldsView…)).
111
+ fields: () => `${prefix}/fields/`,
112
+
113
+ run: (funnelId) => `${prefix}/${segment(funnelId)}/run/`,
114
+ runs: (funnelId) => `${prefix}/${segment(funnelId)}/runs/`,
115
+ runDetail: (runId, funnelId) => `${runBase(runId, funnelId)}/`,
116
+ runResults: (runId, funnelId) => `${runBase(runId, funnelId)}/results/`,
117
+ runCancel: (runId, funnelId) => `${runBase(runId, funnelId)}/cancel/`,
118
+ };
119
+ }
120
+
121
+ /** The central API family: what the client used to hardcode. */
122
+ export const centralFunnelPaths: FunnelPaths = createFunnelPaths();
123
+
124
+ /**
125
+ * The control-plane family for one foundry (startsim-9xbz2):
126
+ * /api/v1/foundry/tenants/<slug>/funnels/… with runs nested under their funnel.
127
+ */
128
+ export function foundryTenantFunnelPaths(slug: string): FunnelPaths {
129
+ return createFunnelPaths({
130
+ prefix: `/api/v1/foundry/tenants/${segment(slug)}/funnels`,
131
+ nestRuns: true,
132
+ });
133
+ }
134
+
135
+ /**
136
+ * The same central family reached through a fork's same-origin proxy, e.g.
137
+ * '/central-api'. A tenant page cannot call the central API cross-origin
138
+ * (project_foreign_apex_sso_same_origin_proxy), so it proxies it.
139
+ */
140
+ export function proxiedCentralFunnelPaths(proxyPrefix: string): FunnelPaths {
141
+ const base = trimSlashes(proxyPrefix);
142
+ return createFunnelPaths({
143
+ prefix: `${base}${DEFAULT_FUNNEL_PATH_PREFIX}`,
144
+ runsPrefix: `${base}${DEFAULT_FUNNEL_RUNS_PATH_PREFIX}`,
145
+ });
146
+ }
@@ -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
+ });
@@ -38,7 +38,14 @@
38
38
  * - Focus management when adding/removing rules
39
39
  */
40
40
 
41
- import { FilterRule, FieldDefinition, Operator } from '../../types';
41
+ import { FilterRule, FieldDefinitionInput, Operator } from '../../types';
42
+ import {
43
+ fieldKey,
44
+ fieldOperators,
45
+ fieldValueType,
46
+ normalizeFieldDefinition,
47
+ ruleField,
48
+ } from '../../types';
42
49
  import { FieldSelector } from './FieldSelector';
43
50
  import { OperatorSelector } from './OperatorSelector';
44
51
  import { NULL_VALUE_OPERATORS, MULTI_VALUE_OPERATORS } from './constants';
@@ -55,7 +62,12 @@ interface RuleRowProps {
55
62
  rule: FilterRule;
56
63
  onChange: (rule: FilterRule) => void;
57
64
  onRemove: () => void;
58
- fieldRegistry: FieldDefinition[];
65
+ /**
66
+ * Accepts the canonical /fields/ shape AND the hand-written
67
+ * {name,type,operators} registries that predate it — every read goes through
68
+ * the accessors (bd startsim-pxzlf).
69
+ */
70
+ fieldRegistry: FieldDefinitionInput[];
59
71
  className?: string;
60
72
  }
61
73
 
@@ -66,11 +78,27 @@ export function RuleRow({
66
78
  fieldRegistry,
67
79
  className = '',
68
80
  }: RuleRowProps) {
69
- // Find the selected field definition
70
- const selectedField = fieldRegistry.find((f) => f.name === rule.fieldPath);
81
+ // Which of the rule's two keys this registry can actually resolve.
82
+ //
83
+ // A rule the backend auto-migrated carries BOTH: the serializer's legacy
84
+ // branch sets `field` from the adapter and leaves `field_path` in place, and
85
+ // _create_rule_tree persists the whole validated dict. So a rule market saved
86
+ // comes back {field: 'contact.name', fieldPath: 'contact__name'} while its
87
+ // registry is still keyed 'contact__name'. Preferring `field` unconditionally
88
+ // would blank the picker on exactly those rows — and re-saving a blank row
89
+ // drops the rule. Try the canonical key first, fall back to the deprecated
90
+ // one, and only then give up (showing the canonical key, so the user sees
91
+ // which field the row names rather than an empty box).
92
+ const canonicalKey = rule.field || '';
93
+ const legacyKey = rule.fieldPath || '';
94
+ const entryFor = (key: string) =>
95
+ key ? fieldRegistry.find((f) => fieldKey(f) === key) : undefined;
96
+ const selectedRaw = entryFor(canonicalKey) ?? entryFor(legacyKey);
97
+ const selectedKey = selectedRaw ? fieldKey(selectedRaw) : ruleField(rule);
98
+ const selectedField = selectedRaw ? normalizeFieldDefinition(selectedRaw) : undefined;
71
99
 
72
100
  // Get valid operators for the selected field
73
- const availableOperators = selectedField?.operators || [];
101
+ const availableOperators = selectedField?.allowedOperators || [];
74
102
 
75
103
  // Determine if value input is needed
76
104
  const needsValue = rule.operator && !NULL_VALUE_OPERATORS.includes(rule.operator);
@@ -79,11 +107,28 @@ export function RuleRow({
79
107
 
80
108
  // Handle field change
81
109
  const handleFieldChange = (fieldName: string) => {
82
- const field = fieldRegistry.find((f) => f.name === fieldName);
110
+ const entry = fieldRegistry.find((f) => fieldKey(f) === fieldName);
111
+
112
+ // WHAT GOES ON THE WIRE depends on where the name came from.
113
+ //
114
+ // A registry entry carrying `key` came from GET …/funnels/fields/, so the
115
+ // name IS a resolver key and belongs in `field`. A hand-written entry
116
+ // carries only `name` — 'contact__name', 'tag:stage_focus:seed' — which the
117
+ // resolver registry does not know. Putting THAT in `field` would take the
118
+ // serializer's strict branch (`if attrs.get("field")`) and earn a 400;
119
+ // leaving `field` empty takes the legacy branch, which is what those names
120
+ // have always been translated by. `field: ''` is accepted on the wire:
121
+ // FunnelFilterRule.field is a blank=True CharField, so DRF gives it
122
+ // allow_blank=True, and '' is falsy in the branch that matters.
123
+ const isRegistryKey = Boolean(entry && entry.key);
124
+
83
125
  onChange({
84
126
  ...rule,
127
+ field: isRegistryKey ? fieldName : '',
128
+ // The deprecated mirror carries the name either way: it is what the
129
+ // legacy branch reads, and a canonical rule ignores it.
85
130
  fieldPath: fieldName,
86
- operator: field?.operators[0] || ('' as Operator),
131
+ operator: (fieldOperators(entry ?? {})[0] as Operator) || ('' as Operator),
87
132
  value: null,
88
133
  });
89
134
  };
@@ -110,7 +155,8 @@ export function RuleRow({
110
155
  if (!needsValue) return null;
111
156
  if (!selectedField) return null;
112
157
 
113
- const { type, constraints } = selectedField;
158
+ const { constraints } = selectedField;
159
+ const type = fieldValueType(selectedField);
114
160
 
115
161
  // Multi-value operators
116
162
  if (needsMultiValue) {
@@ -135,6 +181,23 @@ export function RuleRow({
135
181
 
136
182
  // Single-value operators
137
183
  switch (type) {
184
+ case 'enum':
185
+ if (constraints?.choices) {
186
+ return (
187
+ <ChoiceValueInput
188
+ value={rule.value || ''}
189
+ onChange={handleValueChange}
190
+ choices={constraints.choices}
191
+ />
192
+ );
193
+ }
194
+ return (
195
+ <TextValueInput
196
+ value={rule.value || ''}
197
+ onChange={handleValueChange}
198
+ />
199
+ );
200
+
138
201
  case 'string':
139
202
  if (constraints?.choices && rule.operator === 'eq') {
140
203
  return (
@@ -228,7 +291,7 @@ export function RuleRow({
228
291
  <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 pr-8">
229
292
  <FieldSelector
230
293
  fields={fieldRegistry}
231
- value={rule.fieldPath}
294
+ value={selectedKey}
232
295
  onChange={handleFieldChange}
233
296
  />
234
297
 
@@ -236,7 +299,7 @@ export function RuleRow({
236
299
  operators={availableOperators}
237
300
  value={rule.operator || ''}
238
301
  onChange={handleOperatorChange}
239
- disabled={!rule.fieldPath}
302
+ disabled={!selectedKey}
240
303
  />
241
304
 
242
305
  {needsValue && renderValueInput()}
@@ -16,6 +16,7 @@ export const OPERATOR_LABELS: Record<Operator, string> = {
16
16
  lt: 'less than',
17
17
  gte: 'greater or equal',
18
18
  lte: 'less or equal',
19
+ between: 'is between',
19
20
 
20
21
  // String operations
21
22
  contains: 'contains',
@@ -30,6 +31,10 @@ export const OPERATOR_LABELS: Record<Operator, string> = {
30
31
  has_any: 'has any of',
31
32
  has_all: 'has all of',
32
33
 
34
+ // Presence
35
+ exists: 'is set',
36
+ not_exists: 'is not set',
37
+
33
38
  // Null checks
34
39
  isnull: 'is empty',
35
40
  isnotnull: 'is not empty',
@@ -47,6 +52,8 @@ export const OPERATOR_LABELS: Record<Operator, string> = {
47
52
  * Operators that don't require a value input
48
53
  */
49
54
  export const NULL_VALUE_OPERATORS: Operator[] = [
55
+ 'exists',
56
+ 'not_exists',
50
57
  'isnull',
51
58
  'isnotnull',
52
59
  'is_true',
@@ -62,3 +69,8 @@ export const MULTI_VALUE_OPERATORS: Operator[] = [
62
69
  'has_any',
63
70
  'has_all',
64
71
  ];
72
+
73
+ /**
74
+ * Operators whose value is a [min, max] pair.
75
+ */
76
+ export const RANGE_OPERATORS: Operator[] = ['between'];