@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.
- package/README.md +326 -281
- package/package.json +14 -14
- package/src/api/client.paths.test.ts +162 -0
- package/src/api/client.ts +72 -19
- package/src/api/index.ts +11 -0
- package/src/api/paths.test.ts +125 -0
- package/src/api/paths.ts +146 -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/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
package/src/api/paths.ts
ADDED
|
@@ -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 {
|
|
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
|
+
});
|
|
@@ -38,7 +38,14 @@
|
|
|
38
38
|
* - Focus management when adding/removing rules
|
|
39
39
|
*/
|
|
40
40
|
|
|
41
|
-
import { FilterRule,
|
|
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
|
-
|
|
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
|
-
//
|
|
70
|
-
|
|
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?.
|
|
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
|
|
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:
|
|
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 {
|
|
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={
|
|
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={!
|
|
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'];
|