@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
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one funnel contract (bd startsim-pxzlf).
|
|
3
|
+
*
|
|
4
|
+
* Three shapes shipped before this: this package's flat {fieldPath} rule,
|
|
5
|
+
* @startsimpli/api's own rule type, and raise's colon-encoded field names. The
|
|
6
|
+
* canonical one is the backend's: FunnelFilterRule.field carries a REGISTRY KEY
|
|
7
|
+
* ('contact.name', 'tag.<category>', 'metric.<type>.<subtype>', 'profile.<type>',
|
|
8
|
+
* 'icp.<signal>'), and GET /api/v1/funnels/fields/ describes those keys.
|
|
9
|
+
*
|
|
10
|
+
* These tests pin the canonical shape AND the tolerance that keeps the two live
|
|
11
|
+
* consumers working: a rule that still carries only `fieldPath`, and a field
|
|
12
|
+
* registry still written as {name,type,operators}, must keep evaluating and
|
|
13
|
+
* rendering.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, it, expect } from 'vitest';
|
|
17
|
+
import {
|
|
18
|
+
fieldKey,
|
|
19
|
+
fieldOperators,
|
|
20
|
+
fieldValueType,
|
|
21
|
+
isFieldDefinition,
|
|
22
|
+
isFilterRule,
|
|
23
|
+
isRuleGroup,
|
|
24
|
+
normalizeFieldDefinition,
|
|
25
|
+
normalizeRule,
|
|
26
|
+
ruleField,
|
|
27
|
+
ruleLeaves,
|
|
28
|
+
validateFilterRule,
|
|
29
|
+
getValidOperators,
|
|
30
|
+
} from './index';
|
|
31
|
+
import type {
|
|
32
|
+
FieldDefinition,
|
|
33
|
+
FieldDefinitionInput,
|
|
34
|
+
FilterRule,
|
|
35
|
+
FunnelStage,
|
|
36
|
+
Funnel,
|
|
37
|
+
RuleGroup,
|
|
38
|
+
} from './index';
|
|
39
|
+
import { evaluateRule } from '../core/evaluator';
|
|
40
|
+
|
|
41
|
+
describe('FilterRule.field — the canonical registry key', () => {
|
|
42
|
+
it('carries the registry key the backend resolves', () => {
|
|
43
|
+
const rule: FilterRule = { field: 'tag.stage_focus', operator: 'eq', value: 'seed' };
|
|
44
|
+
expect(ruleField(rule)).toBe('tag.stage_focus');
|
|
45
|
+
expect(isFilterRule(rule)).toBe(true);
|
|
46
|
+
expect(validateFilterRule(rule)).toEqual([]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('still reads a rule that only carries the deprecated fieldPath', () => {
|
|
50
|
+
const legacy = { fieldPath: 'firm.stage', operator: 'eq', value: 'Series A' } as FilterRule;
|
|
51
|
+
expect(ruleField(legacy)).toBe('firm.stage');
|
|
52
|
+
expect(isFilterRule(legacy)).toBe(true);
|
|
53
|
+
expect(validateFilterRule(legacy)).toEqual([]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('prefers field over fieldPath when a row carries both — the auto-migrated row', () => {
|
|
57
|
+
// Not hypothetical: the serializer's legacy branch sets attrs['field'] and
|
|
58
|
+
// leaves field_path alone, and _create_rule_tree persists the whole dict.
|
|
59
|
+
// Every rule saved through that branch comes back carrying both.
|
|
60
|
+
const both = {
|
|
61
|
+
field: 'contact.name',
|
|
62
|
+
fieldPath: 'contact__name',
|
|
63
|
+
operator: 'eq',
|
|
64
|
+
value: 'Ada',
|
|
65
|
+
} as FilterRule;
|
|
66
|
+
expect(ruleField(both)).toBe('contact.name');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('reports a rule that names no field at all', () => {
|
|
70
|
+
const errors = validateFilterRule({ operator: 'eq', value: 1 } as unknown as FilterRule);
|
|
71
|
+
expect(errors.some((e) => /field/i.test(e))).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('evaluates locally off `field`, not just fieldPath', () => {
|
|
75
|
+
const entity = { firm: { stage: 'Series A' } };
|
|
76
|
+
expect(evaluateRule(entity, { field: 'firm.stage', operator: 'eq', value: 'Series A' })).toBe(
|
|
77
|
+
true
|
|
78
|
+
);
|
|
79
|
+
expect(
|
|
80
|
+
evaluateRule(entity, { fieldPath: 'firm.stage', operator: 'eq', value: 'Seed' } as FilterRule)
|
|
81
|
+
).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('normalizeRule lifts a legacy or snake_case wire rule onto the canonical shape', () => {
|
|
85
|
+
expect(normalizeRule({ fieldPath: 'contact.email', operator: 'eq', value: 'a@b.c' })).toEqual({
|
|
86
|
+
field: 'contact.email',
|
|
87
|
+
operator: 'eq',
|
|
88
|
+
value: 'a@b.c',
|
|
89
|
+
});
|
|
90
|
+
// A row the backend auto-migrated keeps its legacy key; normalizing drops
|
|
91
|
+
// the deprecated one and keeps the canonical.
|
|
92
|
+
expect(
|
|
93
|
+
normalizeRule({
|
|
94
|
+
field: 'contact.name',
|
|
95
|
+
fieldPath: 'contact__name',
|
|
96
|
+
operator: 'contains',
|
|
97
|
+
value: 'Ada',
|
|
98
|
+
})
|
|
99
|
+
).toEqual({ field: 'contact.name', operator: 'contains', value: 'Ada' });
|
|
100
|
+
|
|
101
|
+
// An adapter that does not camelize leaves group_logic snake_cased.
|
|
102
|
+
const group = normalizeRule({
|
|
103
|
+
group_logic: 'any',
|
|
104
|
+
children: [{ field: 'contact.name', operator: 'eq', value: 'Ada' }],
|
|
105
|
+
});
|
|
106
|
+
expect(isRuleGroup(group)).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('the operator vocabulary matches the backend registry', () => {
|
|
111
|
+
it('includes between / exists / not_exists (resolvers/base.py Operator)', () => {
|
|
112
|
+
const rules: FilterRule[] = [
|
|
113
|
+
{ field: 'metric.financial.check_size_min', operator: 'between', value: [1, 10] },
|
|
114
|
+
{ field: 'tag.stage_focus', operator: 'exists', value: null },
|
|
115
|
+
{ field: 'tag.stage_focus', operator: 'not_exists', value: null },
|
|
116
|
+
];
|
|
117
|
+
for (const rule of rules) expect(validateFilterRule(rule)).toEqual([]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('evaluates them locally', () => {
|
|
121
|
+
expect(
|
|
122
|
+
evaluateRule({ n: 5 }, { field: 'n', operator: 'between', value: [1, 10] })
|
|
123
|
+
).toBe(true);
|
|
124
|
+
expect(evaluateRule({ n: 50 }, { field: 'n', operator: 'between', value: [1, 10] })).toBe(
|
|
125
|
+
false
|
|
126
|
+
);
|
|
127
|
+
expect(evaluateRule({ n: 5 }, { field: 'n', operator: 'exists', value: null })).toBe(true);
|
|
128
|
+
expect(evaluateRule({}, { field: 'n', operator: 'exists', value: null })).toBe(false);
|
|
129
|
+
expect(evaluateRule({}, { field: 'n', operator: 'not_exists', value: null })).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('does not demand a value for the operators that take none', () => {
|
|
133
|
+
expect(validateFilterRule({ field: 'tag.x', operator: 'exists' } as FilterRule)).toEqual([]);
|
|
134
|
+
expect(validateFilterRule({ field: 'tag.x', operator: 'not_exists' } as FilterRule)).toEqual(
|
|
135
|
+
[]
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('offers between for numbers and dates', () => {
|
|
140
|
+
expect(getValidOperators('number')).toContain('between');
|
|
141
|
+
expect(getValidOperators('date')).toContain('between');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('offers exists / not_exists for an enum field, the shape a tag category has', () => {
|
|
145
|
+
expect(getValidOperators('enum')).toEqual(
|
|
146
|
+
expect.arrayContaining(['eq', 'ne', 'in', 'not_in', 'exists', 'not_exists'])
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe('FieldDefinition is shaped like the /fields/ payload', () => {
|
|
152
|
+
const fromBackend: FieldDefinition = {
|
|
153
|
+
key: 'tag.stage_focus',
|
|
154
|
+
label: 'stage_focus',
|
|
155
|
+
category: 'Tags',
|
|
156
|
+
valueType: 'enum',
|
|
157
|
+
enumValues: ['pre_seed', 'seed', 'series_a'],
|
|
158
|
+
allowedOperators: ['eq', 'ne', 'in', 'not_in', 'exists', 'not_exists'],
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
it('accepts the payload verbatim', () => {
|
|
162
|
+
expect(isFieldDefinition(fromBackend)).toBe(true);
|
|
163
|
+
expect(fieldKey(fromBackend)).toBe('tag.stage_focus');
|
|
164
|
+
expect(fieldValueType(fromBackend)).toBe('enum');
|
|
165
|
+
expect(fieldOperators(fromBackend)).toContain('not_exists');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('accepts a registry still written the old way — {name,type,operators}', () => {
|
|
169
|
+
const legacy: FieldDefinitionInput = {
|
|
170
|
+
name: 'contact__name',
|
|
171
|
+
label: 'Name',
|
|
172
|
+
type: 'string',
|
|
173
|
+
operators: ['eq', 'contains'],
|
|
174
|
+
};
|
|
175
|
+
expect(isFieldDefinition(legacy)).toBe(true);
|
|
176
|
+
expect(fieldKey(legacy)).toBe('contact__name');
|
|
177
|
+
expect(fieldValueType(legacy)).toBe('string');
|
|
178
|
+
expect(fieldOperators(legacy)).toEqual(['eq', 'contains']);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('accepts the raw snake_case payload, for an adapter that does not camelize', () => {
|
|
182
|
+
const wire: FieldDefinitionInput = {
|
|
183
|
+
key: 'metric.financial.check_size_min',
|
|
184
|
+
label: 'check_size_min',
|
|
185
|
+
category: 'Financial',
|
|
186
|
+
value_type: 'number',
|
|
187
|
+
enum_values: [],
|
|
188
|
+
allowed_operators: ['gte', 'lte', 'between'],
|
|
189
|
+
};
|
|
190
|
+
const normalized = normalizeFieldDefinition(wire);
|
|
191
|
+
expect(normalized.key).toBe('metric.financial.check_size_min');
|
|
192
|
+
expect(normalized.valueType).toBe('number');
|
|
193
|
+
expect(normalized.allowedOperators).toEqual(['gte', 'lte', 'between']);
|
|
194
|
+
expect(normalized.enumValues).toEqual([]);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('normalizes a legacy definition onto the canonical keys without losing anything', () => {
|
|
198
|
+
const normalized = normalizeFieldDefinition({
|
|
199
|
+
name: 'contact__email',
|
|
200
|
+
label: 'Email',
|
|
201
|
+
type: 'string',
|
|
202
|
+
operators: ['eq'],
|
|
203
|
+
category: 'Contact',
|
|
204
|
+
});
|
|
205
|
+
expect(normalized).toMatchObject({
|
|
206
|
+
key: 'contact__email',
|
|
207
|
+
label: 'Email',
|
|
208
|
+
valueType: 'string',
|
|
209
|
+
allowedOperators: ['eq'],
|
|
210
|
+
category: 'Contact',
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('turns an enum definition into choices the value inputs can render', () => {
|
|
215
|
+
const normalized = normalizeFieldDefinition(fromBackend);
|
|
216
|
+
expect(normalized.constraints?.choices).toEqual(['pre_seed', 'seed', 'series_a']);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
describe('entityType is a string, not a two-value union', () => {
|
|
221
|
+
it('takes any entity type the backend models', () => {
|
|
222
|
+
// Funnel.entity_type is a CharField with no choices: 'investor', 'recipe',
|
|
223
|
+
// 'lead', a foundry's own type — 'contact' | 'organization' was a guess.
|
|
224
|
+
const funnel: Funnel = {
|
|
225
|
+
id: 'f1',
|
|
226
|
+
name: 'Artists in reach',
|
|
227
|
+
status: 'active',
|
|
228
|
+
entityType: 'sc_artist',
|
|
229
|
+
stages: [],
|
|
230
|
+
createdAt: '2026-09-16T00:00:00Z',
|
|
231
|
+
updatedAt: '2026-09-16T00:00:00Z',
|
|
232
|
+
};
|
|
233
|
+
expect(funnel.entityType).toBe('sc_artist');
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe('rule groups', () => {
|
|
238
|
+
const group: RuleGroup = {
|
|
239
|
+
groupLogic: 'any',
|
|
240
|
+
children: [
|
|
241
|
+
{ field: 'tag.stage_focus', operator: 'eq', value: 'seed' },
|
|
242
|
+
{
|
|
243
|
+
groupLogic: 'all',
|
|
244
|
+
children: [
|
|
245
|
+
{ field: 'metric.financial.check_size_min', operator: 'gte', value: 100000 },
|
|
246
|
+
{ field: 'contact.location', operator: 'contains', value: 'SF' },
|
|
247
|
+
],
|
|
248
|
+
},
|
|
249
|
+
],
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
it('tells a group from a leaf', () => {
|
|
253
|
+
expect(isRuleGroup(group)).toBe(true);
|
|
254
|
+
expect(isRuleGroup({ field: 'contact.name', operator: 'eq', value: 'Ada' })).toBe(false);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('flattens a tree to its leaves in document order', () => {
|
|
258
|
+
expect(ruleLeaves([group]).map(ruleField)).toEqual([
|
|
259
|
+
'tag.stage_focus',
|
|
260
|
+
'metric.financial.check_size_min',
|
|
261
|
+
'contact.location',
|
|
262
|
+
]);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('leaves a flat list alone', () => {
|
|
266
|
+
const flat: FilterRule[] = [{ field: 'contact.name', operator: 'eq', value: 'Ada' }];
|
|
267
|
+
expect(ruleLeaves(flat)).toEqual(flat);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
describe('a stage still carries its flat leaves', () => {
|
|
272
|
+
it('keeps rules[] as leaves so every existing consumer keeps reading it', () => {
|
|
273
|
+
const stage: FunnelStage = {
|
|
274
|
+
id: 's1',
|
|
275
|
+
order: 0,
|
|
276
|
+
name: 'Qualified',
|
|
277
|
+
filterLogic: 'AND',
|
|
278
|
+
rules: [{ field: 'contact.email', operator: 'exists', value: null }],
|
|
279
|
+
matchAction: 'continue',
|
|
280
|
+
noMatchAction: 'exclude',
|
|
281
|
+
};
|
|
282
|
+
expect(ruleField(stage.rules[0])).toBe('contact.email');
|
|
283
|
+
});
|
|
284
|
+
});
|