@startsimpli/funnels 0.4.13 → 0.4.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +326 -281
- package/package.json +24 -16
- package/src/api/client.paths.test.ts +175 -0
- package/src/api/client.ts +78 -19
- package/src/api/http-adapter.test.ts +68 -0
- package/src/api/http-adapter.ts +48 -0
- package/src/api/index.ts +16 -0
- package/src/api/paths.test.ts +138 -0
- package/src/api/paths.ts +192 -0
- package/src/components/FilterRuleEditor/FieldSelector.tsx +7 -5
- package/src/components/FilterRuleEditor/FilterRuleEditor.stories.tsx +3 -3
- package/src/components/FilterRuleEditor/FilterRuleEditor.test.tsx +13 -8
- package/src/components/FilterRuleEditor/FilterRuleEditor.tsx +7 -2
- package/src/components/FilterRuleEditor/OperatorSelector.tsx +1 -1
- package/src/components/FilterRuleEditor/RuleRow.test.tsx +166 -0
- package/src/components/FilterRuleEditor/RuleRow.tsx +73 -10
- package/src/components/FilterRuleEditor/constants.ts +12 -0
- package/src/components/FunnelPreview/example.tsx +15 -15
- package/src/components/FunnelStageBuilder/FunnelStageBuilder.stories.tsx +2 -2
- package/src/components/FunnelStageBuilder/FunnelStageBuilder.test.tsx +2 -2
- package/src/components/FunnelStageBuilder/FunnelStageBuilder.tsx +2 -2
- package/src/components/FunnelStageBuilder/StageCard.tsx +2 -2
- package/src/components/FunnelStageBuilder/StageForm.tsx +2 -2
- package/src/core/evaluator.example.ts +23 -23
- package/src/core/evaluator.test.ts +4 -1
- package/src/core/evaluator.ts +11 -5
- package/src/core/operators.ts +35 -0
- package/src/page/FunnelsPage.test.tsx +383 -0
- package/src/page/FunnelsPage.tsx +739 -0
- package/src/page/index.ts +24 -0
- package/src/page/types.ts +102 -0
- package/src/store/create-funnel-store.ts +1 -0
- package/src/stories/demo-data/investors.ts +2 -2
- package/src/stories/demo-data/leads.ts +2 -2
- package/src/stories/demo-data/recipes.ts +2 -2
- package/src/types/contract.test.ts +284 -0
- package/src/types/index.ts +339 -49
|
@@ -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'];
|
|
@@ -17,7 +17,7 @@ const investorFunnel: Funnel = {
|
|
|
17
17
|
name: 'Investor Qualification',
|
|
18
18
|
description: 'Filter investors by firm stage and check size',
|
|
19
19
|
status: 'active',
|
|
20
|
-
|
|
20
|
+
entityType: 'investor',
|
|
21
21
|
stages: [
|
|
22
22
|
{
|
|
23
23
|
id: 'stage-1',
|
|
@@ -25,9 +25,9 @@ const investorFunnel: Funnel = {
|
|
|
25
25
|
name: 'Series A or Later',
|
|
26
26
|
filterLogic: 'OR',
|
|
27
27
|
rules: [
|
|
28
|
-
{
|
|
29
|
-
{
|
|
30
|
-
{
|
|
28
|
+
{ field: 'firm.stage', operator: 'eq', value: 'Series A' },
|
|
29
|
+
{ field: 'firm.stage', operator: 'eq', value: 'Series B' },
|
|
30
|
+
{ field: 'firm.stage', operator: 'eq', value: 'Series C' },
|
|
31
31
|
],
|
|
32
32
|
matchAction: 'continue',
|
|
33
33
|
noMatchAction: 'exclude',
|
|
@@ -37,7 +37,7 @@ const investorFunnel: Funnel = {
|
|
|
37
37
|
order: 1,
|
|
38
38
|
name: 'High ICP Score',
|
|
39
39
|
filterLogic: 'AND',
|
|
40
|
-
rules: [{
|
|
40
|
+
rules: [{ field: 'icp_score', operator: 'gte', value: 80 }],
|
|
41
41
|
matchAction: 'output',
|
|
42
42
|
noMatchAction: 'exclude',
|
|
43
43
|
},
|
|
@@ -112,14 +112,14 @@ const recipeFunnel: Funnel = {
|
|
|
112
112
|
name: 'Recipe Recommendations',
|
|
113
113
|
description: 'Filter recipes by dietary restrictions and difficulty',
|
|
114
114
|
status: 'active',
|
|
115
|
-
|
|
115
|
+
entityType: 'recipe',
|
|
116
116
|
stages: [
|
|
117
117
|
{
|
|
118
118
|
id: 'dietary',
|
|
119
119
|
order: 0,
|
|
120
120
|
name: 'Vegetarian',
|
|
121
121
|
filterLogic: 'AND',
|
|
122
|
-
rules: [{
|
|
122
|
+
rules: [{ field: 'tags', operator: 'has_tag', value: 'vegetarian' }],
|
|
123
123
|
matchAction: 'continue',
|
|
124
124
|
noMatchAction: 'exclude',
|
|
125
125
|
},
|
|
@@ -129,8 +129,8 @@ const recipeFunnel: Funnel = {
|
|
|
129
129
|
name: 'Easy to Moderate',
|
|
130
130
|
filterLogic: 'OR',
|
|
131
131
|
rules: [
|
|
132
|
-
{
|
|
133
|
-
{
|
|
132
|
+
{ field: 'difficulty', operator: 'eq', value: 'easy' },
|
|
133
|
+
{ field: 'difficulty', operator: 'eq', value: 'moderate' },
|
|
134
134
|
],
|
|
135
135
|
matchAction: 'output',
|
|
136
136
|
noMatchAction: 'exclude',
|
|
@@ -198,14 +198,14 @@ const leadFunnel: Funnel = {
|
|
|
198
198
|
name: 'Qualified Leads',
|
|
199
199
|
description: 'Score and filter leads based on company size and engagement',
|
|
200
200
|
status: 'active',
|
|
201
|
-
|
|
201
|
+
entityType: 'lead',
|
|
202
202
|
stages: [
|
|
203
203
|
{
|
|
204
204
|
id: 'company-size',
|
|
205
205
|
order: 0,
|
|
206
206
|
name: 'Mid-Market and Above',
|
|
207
207
|
filterLogic: 'AND',
|
|
208
|
-
rules: [{
|
|
208
|
+
rules: [{ field: 'company.employees', operator: 'gte', value: 50 }],
|
|
209
209
|
matchAction: 'continue',
|
|
210
210
|
noMatchAction: 'exclude',
|
|
211
211
|
},
|
|
@@ -214,7 +214,7 @@ const leadFunnel: Funnel = {
|
|
|
214
214
|
order: 1,
|
|
215
215
|
name: 'High Engagement',
|
|
216
216
|
filterLogic: 'AND',
|
|
217
|
-
rules: [{
|
|
217
|
+
rules: [{ field: 'engagement_score', operator: 'gte', value: 7 }],
|
|
218
218
|
matchAction: 'tag_continue',
|
|
219
219
|
noMatchAction: 'continue',
|
|
220
220
|
matchTags: ['high-engagement'],
|
|
@@ -225,9 +225,9 @@ const leadFunnel: Funnel = {
|
|
|
225
225
|
name: 'Target Industries',
|
|
226
226
|
filterLogic: 'OR',
|
|
227
227
|
rules: [
|
|
228
|
-
{
|
|
229
|
-
{
|
|
230
|
-
{
|
|
228
|
+
{ field: 'company.industry', operator: 'eq', value: 'Technology' },
|
|
229
|
+
{ field: 'company.industry', operator: 'eq', value: 'SaaS' },
|
|
230
|
+
{ field: 'company.industry', operator: 'eq', value: 'FinTech' },
|
|
231
231
|
],
|
|
232
232
|
matchAction: 'output',
|
|
233
233
|
noMatchAction: 'exclude',
|
|
@@ -5,11 +5,11 @@
|
|
|
5
5
|
import type { Meta, StoryObj } from '@storybook/react';
|
|
6
6
|
import { useState } from 'react';
|
|
7
7
|
import { FunnelStageBuilder } from './FunnelStageBuilder';
|
|
8
|
-
import { Funnel,
|
|
8
|
+
import { Funnel, FieldDefinitionInput } from '../../types';
|
|
9
9
|
import './FunnelStageBuilder.css';
|
|
10
10
|
|
|
11
11
|
// Sample field registry
|
|
12
|
-
const sampleFieldRegistry:
|
|
12
|
+
const sampleFieldRegistry: FieldDefinitionInput[] = [
|
|
13
13
|
// Firm fields
|
|
14
14
|
{
|
|
15
15
|
name: 'firm.name',
|
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
import { describe, it, expect, vi } from 'vitest';
|
|
6
6
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
7
7
|
import { FunnelStageBuilder } from './FunnelStageBuilder';
|
|
8
|
-
import { Funnel,
|
|
8
|
+
import { Funnel, FieldDefinitionInput } from '../../types';
|
|
9
9
|
|
|
10
10
|
// Mock field registry
|
|
11
|
-
const mockFieldRegistry:
|
|
11
|
+
const mockFieldRegistry: FieldDefinitionInput[] = [
|
|
12
12
|
{
|
|
13
13
|
name: 'firm.stage',
|
|
14
14
|
label: 'Firm Stage',
|
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
sortableKeyboardCoordinates,
|
|
26
26
|
verticalListSortingStrategy,
|
|
27
27
|
} from '@dnd-kit/sortable';
|
|
28
|
-
import { Funnel, FunnelStage,
|
|
28
|
+
import { Funnel, FunnelStage, FieldDefinitionInput } from '../../types';
|
|
29
29
|
import { StageCard } from './StageCard';
|
|
30
30
|
import { AddStageButton } from './AddStageButton';
|
|
31
31
|
|
|
@@ -37,7 +37,7 @@ export interface FunnelStageBuilderProps {
|
|
|
37
37
|
onUpdate: (funnel: Funnel) => void;
|
|
38
38
|
|
|
39
39
|
/** Available fields for filter rules */
|
|
40
|
-
fieldRegistry:
|
|
40
|
+
fieldRegistry: FieldDefinitionInput[];
|
|
41
41
|
|
|
42
42
|
/** Optional CSS class */
|
|
43
43
|
className?: string;
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import React from 'react';
|
|
13
13
|
import { useSortable } from '@dnd-kit/sortable';
|
|
14
14
|
import { CSS } from '@dnd-kit/utilities';
|
|
15
|
-
import { FunnelStage,
|
|
15
|
+
import { FunnelStage, FieldDefinitionInput } from '../../types';
|
|
16
16
|
import { StageForm } from './StageForm';
|
|
17
17
|
|
|
18
18
|
export interface StageCardProps {
|
|
@@ -32,7 +32,7 @@ export interface StageCardProps {
|
|
|
32
32
|
onRemove: () => void;
|
|
33
33
|
|
|
34
34
|
/** Available fields */
|
|
35
|
-
fieldRegistry:
|
|
35
|
+
fieldRegistry: FieldDefinitionInput[];
|
|
36
36
|
|
|
37
37
|
/** Validation error */
|
|
38
38
|
error?: string;
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import React, { useCallback, useState } from 'react';
|
|
14
|
-
import { FunnelStage,
|
|
14
|
+
import { FunnelStage, FieldDefinitionInput, FilterLogic, MatchAction, NoMatchAction, FilterRule } from '../../types';
|
|
15
15
|
import { StageActions } from './StageActions';
|
|
16
16
|
import { TagInput } from './TagInput';
|
|
17
17
|
import { FilterRuleEditor } from '../FilterRuleEditor/FilterRuleEditor';
|
|
@@ -24,7 +24,7 @@ export interface StageFormProps {
|
|
|
24
24
|
onUpdate: (stage: FunnelStage) => void;
|
|
25
25
|
|
|
26
26
|
/** Available fields */
|
|
27
|
-
fieldRegistry:
|
|
27
|
+
fieldRegistry: FieldDefinitionInput[];
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
/**
|
|
@@ -72,7 +72,7 @@ const investors: Investor[] = [
|
|
|
72
72
|
// Example 1a: Single rule evaluation
|
|
73
73
|
console.log('=== Example 1a: Single Rule ===');
|
|
74
74
|
const rule1: FilterRule = {
|
|
75
|
-
|
|
75
|
+
field: 'firm.aum',
|
|
76
76
|
operator: 'gte',
|
|
77
77
|
value: 100000000,
|
|
78
78
|
};
|
|
@@ -85,9 +85,9 @@ investors.forEach(investor => {
|
|
|
85
85
|
// Example 1b: Multiple rules with AND logic
|
|
86
86
|
console.log('\n=== Example 1b: Multiple Rules (AND) ===');
|
|
87
87
|
const rules1: FilterRule[] = [
|
|
88
|
-
{
|
|
89
|
-
{
|
|
90
|
-
{
|
|
88
|
+
{ field: 'firm.stage', operator: 'in', value: ['Series A', 'Series B'] },
|
|
89
|
+
{ field: 'firm.aum', operator: 'gte', value: 100000000 },
|
|
90
|
+
{ field: 'tags', operator: 'has_tag', value: 'qualified' },
|
|
91
91
|
];
|
|
92
92
|
|
|
93
93
|
const qualified = filterEntities(investors, rules1, 'AND');
|
|
@@ -151,7 +151,7 @@ const recipes: Recipe[] = [
|
|
|
151
151
|
// Example 2a: Filter by dietary restrictions
|
|
152
152
|
console.log('\n=== Example 2: Recipe Filtering ===');
|
|
153
153
|
const veganRules: FilterRule[] = [
|
|
154
|
-
{
|
|
154
|
+
{ field: 'dietary', operator: 'has_tag', value: 'vegan' },
|
|
155
155
|
];
|
|
156
156
|
|
|
157
157
|
const veganRecipes = filterEntities(recipes, veganRules);
|
|
@@ -159,9 +159,9 @@ console.log('Vegan recipes:', veganRecipes.map(r => r.name));
|
|
|
159
159
|
|
|
160
160
|
// Example 2b: Filter by multiple criteria (quick meals)
|
|
161
161
|
const quickMealRules: FilterRule[] = [
|
|
162
|
-
{
|
|
163
|
-
{
|
|
164
|
-
{
|
|
162
|
+
{ field: 'prep_time_minutes', operator: 'lte', value: 30 },
|
|
163
|
+
{ field: 'difficulty', operator: 'in', value: ['easy', 'medium'] },
|
|
164
|
+
{ field: 'rating', operator: 'gte', value: 4.0 },
|
|
165
165
|
];
|
|
166
166
|
|
|
167
167
|
const quickMeals = filterEntities(recipes, quickMealRules, 'AND');
|
|
@@ -222,9 +222,9 @@ const repos: GitHubRepo[] = [
|
|
|
222
222
|
// Example 3a: Find popular active repos
|
|
223
223
|
console.log('\n=== Example 3: GitHub Repos ===');
|
|
224
224
|
const popularRepoRules: FilterRule[] = [
|
|
225
|
-
{
|
|
226
|
-
{
|
|
227
|
-
{
|
|
225
|
+
{ field: 'stars', operator: 'gte', value: 100 },
|
|
226
|
+
{ field: 'is_archived', operator: 'is_false', value: null },
|
|
227
|
+
{ field: 'last_updated', operator: 'gte', value: new Date('2024-01-01') },
|
|
228
228
|
];
|
|
229
229
|
|
|
230
230
|
const popularRepos = filterEntities(repos, popularRepoRules, 'AND');
|
|
@@ -232,8 +232,8 @@ console.log('Popular active repos:', popularRepos.map(r => r.name));
|
|
|
232
232
|
|
|
233
233
|
// Example 3b: Find TypeScript projects with React
|
|
234
234
|
const tsReactRules: FilterRule[] = [
|
|
235
|
-
{
|
|
236
|
-
{
|
|
235
|
+
{ field: 'language', operator: 'eq', value: 'TypeScript' },
|
|
236
|
+
{ field: 'topics', operator: 'has_tag', value: 'react' },
|
|
237
237
|
];
|
|
238
238
|
|
|
239
239
|
const tsReactRepos = filterEntities(repos, tsReactRules, 'AND');
|
|
@@ -247,7 +247,7 @@ console.log('\n=== Example 4: Advanced Patterns ===');
|
|
|
247
247
|
|
|
248
248
|
// Pattern 1: Negation
|
|
249
249
|
const notArchivedRule: FilterRule = {
|
|
250
|
-
|
|
250
|
+
field: 'is_archived',
|
|
251
251
|
operator: 'is_true',
|
|
252
252
|
value: null,
|
|
253
253
|
negate: true, // Negate the result
|
|
@@ -258,7 +258,7 @@ console.log('Active repos (using negate):', activeRepos.map(r => r.name));
|
|
|
258
258
|
|
|
259
259
|
// Pattern 2: Complex string matching
|
|
260
260
|
const emailDomainRule: FilterRule = {
|
|
261
|
-
|
|
261
|
+
field: 'email',
|
|
262
262
|
operator: 'matches',
|
|
263
263
|
value: '@acme\\.vc$', // Matches emails ending with @acme.vc
|
|
264
264
|
};
|
|
@@ -268,8 +268,8 @@ console.log('Acme VC investors:', acmeInvestors.map(i => i.name));
|
|
|
268
268
|
|
|
269
269
|
// Pattern 3: OR logic (match any condition)
|
|
270
270
|
const flexibleStageRules: FilterRule[] = [
|
|
271
|
-
{
|
|
272
|
-
{
|
|
271
|
+
{ field: 'firm.stage', operator: 'eq', value: 'Series A' },
|
|
272
|
+
{ field: 'firm.aum', operator: 'gte', value: 500000000 }, // OR very large fund
|
|
273
273
|
];
|
|
274
274
|
|
|
275
275
|
const flexibleMatch = filterEntities(investors, flexibleStageRules, 'OR');
|
|
@@ -286,7 +286,7 @@ investorsWithoutEmail.push({
|
|
|
286
286
|
});
|
|
287
287
|
|
|
288
288
|
const hasEmailRule: FilterRule = {
|
|
289
|
-
|
|
289
|
+
field: 'email',
|
|
290
290
|
operator: 'isnotnull',
|
|
291
291
|
value: null,
|
|
292
292
|
};
|
|
@@ -296,7 +296,7 @@ console.log('Investors with email:', withEmail.map(i => i.name));
|
|
|
296
296
|
|
|
297
297
|
// Pattern 5: Array operations
|
|
298
298
|
const multiGeographyRule: FilterRule = {
|
|
299
|
-
|
|
299
|
+
field: 'firm.geography',
|
|
300
300
|
operator: 'has_all',
|
|
301
301
|
value: ['US', 'Europe'],
|
|
302
302
|
};
|
|
@@ -329,22 +329,22 @@ function simulateFunnel<T>(entities: T[], stages: Array<{ name: string; rules: F
|
|
|
329
329
|
const investorFunnelStages = [
|
|
330
330
|
{
|
|
331
331
|
name: 'Active & Not Archived',
|
|
332
|
-
rules: [{
|
|
332
|
+
rules: [{ field: 'tags', operator: 'has_tag', value: 'active' } as FilterRule],
|
|
333
333
|
logic: 'AND' as const,
|
|
334
334
|
},
|
|
335
335
|
{
|
|
336
336
|
name: 'Series A+ Stage',
|
|
337
|
-
rules: [{
|
|
337
|
+
rules: [{ field: 'firm.stage', operator: 'in', value: ['Series A', 'Series B', 'Series C'] } as FilterRule],
|
|
338
338
|
logic: 'AND' as const,
|
|
339
339
|
},
|
|
340
340
|
{
|
|
341
341
|
name: 'Minimum AUM $100M',
|
|
342
|
-
rules: [{
|
|
342
|
+
rules: [{ field: 'firm.aum', operator: 'gte', value: 100000000 } as FilterRule],
|
|
343
343
|
logic: 'AND' as const,
|
|
344
344
|
},
|
|
345
345
|
{
|
|
346
346
|
name: 'Qualified Tag',
|
|
347
|
-
rules: [{
|
|
347
|
+
rules: [{ field: 'tags', operator: 'has_tag', value: 'qualified' } as FilterRule],
|
|
348
348
|
logic: 'AND' as const,
|
|
349
349
|
},
|
|
350
350
|
];
|
|
@@ -544,10 +544,13 @@ describe('Multiple Rules', () => {
|
|
|
544
544
|
|
|
545
545
|
describe('Detailed Results', () => {
|
|
546
546
|
it('should return detailed rule result', () => {
|
|
547
|
-
const rule: FilterRule = {
|
|
547
|
+
const rule: FilterRule = { field: 'firm.stage', operator: 'eq', value: 'Series A' };
|
|
548
548
|
const result = evaluateRuleWithResult(sampleEntity, rule);
|
|
549
549
|
|
|
550
550
|
expect(result).toEqual({
|
|
551
|
+
// `field` is the canonical key; `fieldPath` mirrors it for readers
|
|
552
|
+
// written before the one-contract change (bd startsim-pxzlf).
|
|
553
|
+
field: 'firm.stage',
|
|
551
554
|
fieldPath: 'firm.stage',
|
|
552
555
|
operator: 'eq',
|
|
553
556
|
value: 'Series A',
|
package/src/core/evaluator.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import type { FilterRule, RuleResult } from '../types';
|
|
14
|
+
import { ruleField } from '../types';
|
|
14
15
|
import { resolveField } from './field-resolver';
|
|
15
16
|
import { applyOperator } from './operators';
|
|
16
17
|
|
|
@@ -40,8 +41,10 @@ import { applyOperator } from './operators';
|
|
|
40
41
|
* ```
|
|
41
42
|
*/
|
|
42
43
|
export function evaluateRule<T>(entity: T, rule: FilterRule): boolean {
|
|
43
|
-
// Resolve field value from entity
|
|
44
|
-
|
|
44
|
+
// Resolve field value from entity. `ruleField` reads the canonical `field`
|
|
45
|
+
// and falls back to the deprecated `fieldPath`, so a rule persisted under
|
|
46
|
+
// either shape evaluates the same.
|
|
47
|
+
const actualValue = resolveField(entity, ruleField(rule));
|
|
45
48
|
|
|
46
49
|
// Apply operator
|
|
47
50
|
const result = applyOperator(rule.operator, actualValue, rule.value);
|
|
@@ -77,9 +80,10 @@ export function evaluateRuleWithResult<T>(
|
|
|
77
80
|
entity: T,
|
|
78
81
|
rule: FilterRule
|
|
79
82
|
): RuleResult {
|
|
83
|
+
const field = ruleField(rule);
|
|
80
84
|
try {
|
|
81
85
|
// Resolve field value
|
|
82
|
-
const actualValue = resolveField(entity,
|
|
86
|
+
const actualValue = resolveField(entity, field);
|
|
83
87
|
|
|
84
88
|
// Apply operator
|
|
85
89
|
const operatorResult = applyOperator(rule.operator, actualValue, rule.value);
|
|
@@ -88,7 +92,8 @@ export function evaluateRuleWithResult<T>(
|
|
|
88
92
|
const matched = rule.negate ? !operatorResult : operatorResult;
|
|
89
93
|
|
|
90
94
|
return {
|
|
91
|
-
|
|
95
|
+
field,
|
|
96
|
+
fieldPath: field,
|
|
92
97
|
operator: rule.operator,
|
|
93
98
|
value: rule.value,
|
|
94
99
|
actualValue: actualValue,
|
|
@@ -97,7 +102,8 @@ export function evaluateRuleWithResult<T>(
|
|
|
97
102
|
} catch (error) {
|
|
98
103
|
// Handle evaluation errors gracefully
|
|
99
104
|
return {
|
|
100
|
-
|
|
105
|
+
field,
|
|
106
|
+
fieldPath: field,
|
|
101
107
|
operator: rule.operator,
|
|
102
108
|
value: rule.value,
|
|
103
109
|
actualValue: undefined,
|
package/src/core/operators.ts
CHANGED
|
@@ -46,6 +46,28 @@ export function applyOperator(
|
|
|
46
46
|
case 'lte':
|
|
47
47
|
return compareValues(actual, expected, (a, e) => a <= e);
|
|
48
48
|
|
|
49
|
+
// Inclusive range. The backend's ValueShape.TUPLE2: value is [min, max].
|
|
50
|
+
case 'between': {
|
|
51
|
+
if (!Array.isArray(expected) || expected.length !== 2) return false;
|
|
52
|
+
const [min, max] = expected;
|
|
53
|
+
return (
|
|
54
|
+
compareValues(actual, min, (a, e) => a >= e) &&
|
|
55
|
+
compareValues(actual, max, (a, e) => a <= e)
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ========================================================================
|
|
60
|
+
// Presence — the backend spelling. A tag category with no value, an
|
|
61
|
+
// attribute that is simply set. Absent, null and '' all count as absent,
|
|
62
|
+
// because a tag row that does not exist and an empty one mean the same
|
|
63
|
+
// thing to a person building a funnel.
|
|
64
|
+
// ========================================================================
|
|
65
|
+
case 'exists':
|
|
66
|
+
return isPresent(actual);
|
|
67
|
+
|
|
68
|
+
case 'not_exists':
|
|
69
|
+
return !isPresent(actual);
|
|
70
|
+
|
|
49
71
|
// ========================================================================
|
|
50
72
|
// String operations (case-insensitive)
|
|
51
73
|
// ========================================================================
|
|
@@ -148,6 +170,19 @@ export function applyOperator(
|
|
|
148
170
|
}
|
|
149
171
|
}
|
|
150
172
|
|
|
173
|
+
/**
|
|
174
|
+
* Whether a resolved value counts as present for exists / not_exists.
|
|
175
|
+
*
|
|
176
|
+
* An empty array is absent: 'tag.stage_focus' resolving to [] means the entity
|
|
177
|
+
* carries no tag in that category, which is exactly what not_exists asks about.
|
|
178
|
+
*/
|
|
179
|
+
function isPresent(actual: any): boolean {
|
|
180
|
+
if (actual === null || actual === undefined) return false;
|
|
181
|
+
if (typeof actual === 'string') return actual.length > 0;
|
|
182
|
+
if (Array.isArray(actual)) return actual.length > 0;
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
|
|
151
186
|
/**
|
|
152
187
|
* Compare values with type normalization
|
|
153
188
|
*
|