@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/types/index.ts
CHANGED
|
@@ -16,6 +16,13 @@
|
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* Filter operators - works with any data type
|
|
19
|
+
*
|
|
20
|
+
* The vocabulary is the backend's: backend/apps/funnels/resolvers/base.py
|
|
21
|
+
* declares eq/ne/in/not_in/gt/lt/gte/lte/between/contains/startswith/endswith/
|
|
22
|
+
* exists/not_exists, and a rule whose operator is outside a field resolver's
|
|
23
|
+
* `allowed_operators` is rejected at POST time. The extra members below
|
|
24
|
+
* (matches, has_any, is_true, …) are client-side evaluation sugar with no
|
|
25
|
+
* server resolver — the local engine understands them, the API will not.
|
|
19
26
|
*/
|
|
20
27
|
export type Operator =
|
|
21
28
|
// Equality
|
|
@@ -27,6 +34,7 @@ export type Operator =
|
|
|
27
34
|
| 'lt' // Less than
|
|
28
35
|
| 'gte' // Greater than or equal
|
|
29
36
|
| 'lte' // Less than or equal
|
|
37
|
+
| 'between' // Within [min, max] (inclusive)
|
|
30
38
|
|
|
31
39
|
// String operations
|
|
32
40
|
| 'contains' // String contains substring
|
|
@@ -41,7 +49,11 @@ export type Operator =
|
|
|
41
49
|
| 'has_any' // Array has any of these values
|
|
42
50
|
| 'has_all' // Array has all of these values
|
|
43
51
|
|
|
44
|
-
//
|
|
52
|
+
// Presence (the backend spelling)
|
|
53
|
+
| 'exists' // Field/tag category is present
|
|
54
|
+
| 'not_exists' // Field/tag category is absent
|
|
55
|
+
|
|
56
|
+
// Null checks (client-side aliases of exists/not_exists)
|
|
45
57
|
| 'isnull' // Field is null/undefined
|
|
46
58
|
| 'isnotnull' // Field is not null/undefined
|
|
47
59
|
|
|
@@ -55,11 +67,15 @@ export type Operator =
|
|
|
55
67
|
|
|
56
68
|
/**
|
|
57
69
|
* Field data types - for operator validation
|
|
70
|
+
*
|
|
71
|
+
* 'string' | 'number' | 'boolean' | 'enum' | 'date' are what the backend field
|
|
72
|
+
* registry emits as `value_type`. The rest are client-side evaluation types.
|
|
58
73
|
*/
|
|
59
74
|
export type FieldType =
|
|
60
75
|
| 'string'
|
|
61
76
|
| 'number'
|
|
62
77
|
| 'boolean'
|
|
78
|
+
| 'enum'
|
|
63
79
|
| 'date'
|
|
64
80
|
| 'array'
|
|
65
81
|
| 'object'
|
|
@@ -71,20 +87,36 @@ export type FieldType =
|
|
|
71
87
|
// ============================================================================
|
|
72
88
|
|
|
73
89
|
/**
|
|
74
|
-
* A single filter rule
|
|
90
|
+
* A single (leaf) filter rule
|
|
91
|
+
*
|
|
92
|
+
* `field` is the CANONICAL key — the same key the backend resolves through
|
|
93
|
+
* backend/apps/funnels/resolvers/registry.py, and the same key
|
|
94
|
+
* GET /api/v1/funnels/fields/ describes:
|
|
75
95
|
*
|
|
76
|
-
*
|
|
77
|
-
* - {
|
|
78
|
-
* - {
|
|
79
|
-
* - {
|
|
80
|
-
* - {
|
|
96
|
+
* - { field: 'contact.name', operator: 'contains', value: 'Ada' }
|
|
97
|
+
* - { field: 'tag.stage_focus', operator: 'eq', value: 'seed' }
|
|
98
|
+
* - { field: 'tag.stage_focus', operator: 'exists' }
|
|
99
|
+
* - { field: 'metric.financial.check_size_min', operator: 'between', value: [1e5, 5e5] }
|
|
100
|
+
* - { field: 'profile.vc', operator: 'exists' }
|
|
101
|
+
* - { field: 'icp.has_frontend_stack', operator: 'eq', value: true }
|
|
102
|
+
*
|
|
103
|
+
* For purely client-side evaluation the key doubles as a dot-notation path into
|
|
104
|
+
* the entity ('firm.stage'), which is what the local engine resolves.
|
|
81
105
|
*/
|
|
82
106
|
export interface FilterRule {
|
|
83
107
|
/**
|
|
84
|
-
*
|
|
85
|
-
*
|
|
108
|
+
* The registry key this rule filters on.
|
|
109
|
+
*
|
|
110
|
+
* Also read as a dot-notation path by the local evaluator, so an in-browser
|
|
111
|
+
* preview over plain objects works with the same rule the server runs.
|
|
86
112
|
*/
|
|
87
|
-
|
|
113
|
+
field: string;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* @deprecated Use `field`. Kept so rows persisted under the old shape keep
|
|
117
|
+
* evaluating and rendering; every reader goes through `ruleField(rule)`.
|
|
118
|
+
*/
|
|
119
|
+
fieldPath?: string;
|
|
88
120
|
|
|
89
121
|
/** Comparison operator */
|
|
90
122
|
operator: Operator;
|
|
@@ -95,18 +127,51 @@ export interface FilterRule {
|
|
|
95
127
|
* - eq/ne/gt/lt/gte/lte: any primitive
|
|
96
128
|
* - contains/startswith/endswith: string
|
|
97
129
|
* - in/not_in: array
|
|
98
|
-
* -
|
|
130
|
+
* - between: [min, max]
|
|
131
|
+
* - exists/not_exists/isnull/isnotnull: omitted (value ignored)
|
|
99
132
|
* - has_tag/not_has_tag: string (tag name)
|
|
100
133
|
*/
|
|
101
|
-
value
|
|
134
|
+
value?: any;
|
|
102
135
|
|
|
103
136
|
/**
|
|
104
137
|
* Optional: negate the rule result
|
|
105
138
|
* Default: false
|
|
106
139
|
*/
|
|
107
140
|
negate?: boolean;
|
|
141
|
+
|
|
142
|
+
/** Server-assigned id, when the rule came back from the API. */
|
|
143
|
+
id?: string | number;
|
|
144
|
+
|
|
145
|
+
/** Position within its parent list. Assigned by the API. */
|
|
146
|
+
order?: number;
|
|
108
147
|
}
|
|
109
148
|
|
|
149
|
+
/**
|
|
150
|
+
* How a group combines its children. Mirrors
|
|
151
|
+
* FunnelFilterRule.GroupLogic on the backend ('leaf' is a leaf rule, i.e. a
|
|
152
|
+
* FilterRule, and never appears on a group).
|
|
153
|
+
*/
|
|
154
|
+
export type GroupLogic = 'all' | 'any' | 'not';
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* A group of rules — the recursive half of the backend's rule tree.
|
|
158
|
+
*
|
|
159
|
+
* The API accepts and emits these nested under a stage's `rules`. Nothing in
|
|
160
|
+
* this package's local engine evaluates a tree yet (a stage evaluates its flat
|
|
161
|
+
* leaves under `filterLogic`); the type exists so a client, a composer or a
|
|
162
|
+
* converter can carry a tree without inventing its own shape — which is
|
|
163
|
+
* exactly what raise had to do.
|
|
164
|
+
*/
|
|
165
|
+
export interface RuleGroup {
|
|
166
|
+
groupLogic: GroupLogic;
|
|
167
|
+
children: FilterRuleNode[];
|
|
168
|
+
id?: string | number;
|
|
169
|
+
order?: number;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** A node of the rule tree: a leaf rule or a group of them. */
|
|
173
|
+
export type FilterRuleNode = FilterRule | RuleGroup;
|
|
174
|
+
|
|
110
175
|
/**
|
|
111
176
|
* Filter logic for combining rules
|
|
112
177
|
*/
|
|
@@ -200,9 +265,21 @@ export type FunnelStatus = 'draft' | 'active' | 'paused' | 'archived';
|
|
|
200
265
|
|
|
201
266
|
/**
|
|
202
267
|
* Input entity types
|
|
268
|
+
*
|
|
269
|
+
* @deprecated Use `Funnel.entityType`. `input_type` is the backend's own
|
|
270
|
+
* deprecated column (models.py: "DEPRECATED: Use entity_type instead").
|
|
203
271
|
*/
|
|
204
272
|
export type InputType = 'contacts' | 'organizations' | 'both' | 'any';
|
|
205
273
|
|
|
274
|
+
/**
|
|
275
|
+
* The kind of entity a funnel runs over.
|
|
276
|
+
*
|
|
277
|
+
* A STRING, deliberately: Funnel.entity_type is a CharField with no choices, so
|
|
278
|
+
* 'contact' | 'organization' was never the real set — 'investor', 'recipe',
|
|
279
|
+
* 'lead', 'sc_artist' and whatever a foundry models are all legitimate.
|
|
280
|
+
*/
|
|
281
|
+
export type EntityType = string;
|
|
282
|
+
|
|
206
283
|
/**
|
|
207
284
|
* A complete funnel definition
|
|
208
285
|
*
|
|
@@ -222,10 +299,19 @@ export interface Funnel<TEntity = any> {
|
|
|
222
299
|
status: FunnelStatus;
|
|
223
300
|
|
|
224
301
|
/**
|
|
225
|
-
* Type of entities this funnel processes
|
|
226
|
-
* Used for field registry lookup
|
|
302
|
+
* Type of entities this funnel processes.
|
|
303
|
+
* Used for field registry lookup (GET …/funnels/fields/?entity_type=…).
|
|
304
|
+
*/
|
|
305
|
+
entityType: EntityType;
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* @deprecated Use `entityType`. The backend keeps `input_type` only for the
|
|
309
|
+
* transition window.
|
|
227
310
|
*/
|
|
228
|
-
inputType
|
|
311
|
+
inputType?: InputType;
|
|
312
|
+
|
|
313
|
+
/** Generic scoping tags: ['fundraise:<uuid>', 'product:raise-simpli']. */
|
|
314
|
+
tags?: string[];
|
|
229
315
|
|
|
230
316
|
/** Ordered stages */
|
|
231
317
|
stages: FunnelStage<TEntity>[];
|
|
@@ -427,7 +513,10 @@ export interface StageResult {
|
|
|
427
513
|
* Result for a single rule evaluation
|
|
428
514
|
*/
|
|
429
515
|
export interface RuleResult {
|
|
430
|
-
/**
|
|
516
|
+
/** The registry key the rule filtered on */
|
|
517
|
+
field: string;
|
|
518
|
+
|
|
519
|
+
/** @deprecated Mirror of `field`, for readers written before the contract. */
|
|
431
520
|
fieldPath: string;
|
|
432
521
|
|
|
433
522
|
/** Rule operator */
|
|
@@ -478,25 +567,45 @@ export interface FieldConstraints {
|
|
|
478
567
|
/**
|
|
479
568
|
* Field definition in registry
|
|
480
569
|
*
|
|
481
|
-
*
|
|
482
|
-
*
|
|
570
|
+
* SHAPED LIKE THE BACKEND PAYLOAD. GET /api/v1/funnels/fields/ returns
|
|
571
|
+
* { key, label, category, value_type, enum_values, allowed_operators } per
|
|
572
|
+
* entry (backend/apps/funnels/resolvers/registry.py `_resolver_to_dict`), and
|
|
573
|
+
* that — not a hand-written per-app list — is what should drive a field picker.
|
|
574
|
+
*
|
|
575
|
+
* The deprecated `name` / `type` / `operators` trio is still accepted so the
|
|
576
|
+
* hand-written registries that predate the endpoint (raise's INVESTOR_FIELDS,
|
|
577
|
+
* market's LEAD_FIELDS) keep rendering. Read through `fieldKey()`,
|
|
578
|
+
* `fieldValueType()` and `fieldOperators()`, or normalize once with
|
|
579
|
+
* `normalizeFieldDefinition()`.
|
|
483
580
|
*/
|
|
484
581
|
export interface FieldDefinition {
|
|
485
|
-
/**
|
|
486
|
-
|
|
582
|
+
/** The registry key: 'contact.name', 'tag.stage_focus', 'metric.<type>.<subtype>'. */
|
|
583
|
+
key: string;
|
|
487
584
|
|
|
488
585
|
/** Human-readable label */
|
|
489
586
|
label: string;
|
|
490
587
|
|
|
491
|
-
/**
|
|
492
|
-
|
|
588
|
+
/** Value type, as the registry reports it */
|
|
589
|
+
valueType: FieldType;
|
|
590
|
+
|
|
591
|
+
/** Operators this field accepts — the server rejects anything else */
|
|
592
|
+
allowedOperators: ValidOperators;
|
|
493
593
|
|
|
494
|
-
/**
|
|
495
|
-
|
|
594
|
+
/** Allowed values, for an enum field (a tag category's known tags) */
|
|
595
|
+
enumValues?: string[];
|
|
496
596
|
|
|
497
597
|
/** Field category (for UI grouping) */
|
|
498
598
|
category?: string;
|
|
499
599
|
|
|
600
|
+
/** @deprecated Use `key`. */
|
|
601
|
+
name?: string;
|
|
602
|
+
|
|
603
|
+
/** @deprecated Use `valueType`. */
|
|
604
|
+
type?: FieldType;
|
|
605
|
+
|
|
606
|
+
/** @deprecated Use `allowedOperators`. */
|
|
607
|
+
operators?: ValidOperators;
|
|
608
|
+
|
|
500
609
|
/** Optional description */
|
|
501
610
|
description?: string;
|
|
502
611
|
|
|
@@ -516,14 +625,41 @@ export interface FieldDefinition {
|
|
|
516
625
|
relatedFields?: string[];
|
|
517
626
|
}
|
|
518
627
|
|
|
628
|
+
/**
|
|
629
|
+
* Anything a caller may hand us as a field definition: the canonical shape, a
|
|
630
|
+
* legacy {name,type,operators} entry, or the raw snake_case payload from an
|
|
631
|
+
* adapter that does not camelize. `normalizeFieldDefinition` collapses all
|
|
632
|
+
* three onto `FieldDefinition`.
|
|
633
|
+
*/
|
|
634
|
+
export interface FieldDefinitionInput {
|
|
635
|
+
key?: string;
|
|
636
|
+
name?: string;
|
|
637
|
+
label?: string;
|
|
638
|
+
category?: string;
|
|
639
|
+
valueType?: FieldType;
|
|
640
|
+
type?: FieldType;
|
|
641
|
+
value_type?: FieldType;
|
|
642
|
+
allowedOperators?: ValidOperators;
|
|
643
|
+
operators?: ValidOperators;
|
|
644
|
+
allowed_operators?: ValidOperators;
|
|
645
|
+
enumValues?: string[];
|
|
646
|
+
enum_values?: string[];
|
|
647
|
+
description?: string;
|
|
648
|
+
constraints?: FieldConstraints;
|
|
649
|
+
sortable?: boolean;
|
|
650
|
+
searchable?: boolean;
|
|
651
|
+
examples?: any[];
|
|
652
|
+
relatedFields?: string[];
|
|
653
|
+
}
|
|
654
|
+
|
|
519
655
|
/**
|
|
520
656
|
* Field registry for an entity type
|
|
521
657
|
*
|
|
522
658
|
* Maps field paths to their definitions
|
|
523
659
|
*/
|
|
524
660
|
export interface FieldRegistry {
|
|
525
|
-
/** Entity type this registry is for */
|
|
526
|
-
entityType:
|
|
661
|
+
/** Entity type this registry is for (null when unfiltered) */
|
|
662
|
+
entityType: EntityType | null;
|
|
527
663
|
|
|
528
664
|
/** Available fields */
|
|
529
665
|
fields: FieldDefinition[];
|
|
@@ -638,9 +774,24 @@ export function isFilterRule(value: unknown): value is FilterRule {
|
|
|
638
774
|
return (
|
|
639
775
|
typeof r === 'object' &&
|
|
640
776
|
r !== null &&
|
|
641
|
-
|
|
642
|
-
typeof r.
|
|
643
|
-
r.
|
|
777
|
+
!isRuleGroup(value) &&
|
|
778
|
+
(typeof r.field === 'string' || typeof r.fieldPath === 'string') &&
|
|
779
|
+
typeof r.operator === 'string'
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* Type guard: is this node a group rather than a leaf?
|
|
785
|
+
*
|
|
786
|
+
* Tolerates the snake_case `group_logic` an adapter that does not camelize
|
|
787
|
+
* leaves on the wire.
|
|
788
|
+
*/
|
|
789
|
+
export function isRuleGroup(value: unknown): value is RuleGroup {
|
|
790
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
791
|
+
const node = value as RuleGroup & { group_logic?: string };
|
|
792
|
+
const logic = node.groupLogic ?? node.group_logic;
|
|
793
|
+
return (
|
|
794
|
+
(logic === 'all' || logic === 'any' || logic === 'not') && Array.isArray(node.children)
|
|
644
795
|
);
|
|
645
796
|
}
|
|
646
797
|
|
|
@@ -678,17 +829,132 @@ export function isFunnelResult<TEntity = any>(
|
|
|
678
829
|
* Type guard: is value a valid FieldDefinition?
|
|
679
830
|
*/
|
|
680
831
|
export function isFieldDefinition(value: unknown): value is FieldDefinition {
|
|
681
|
-
|
|
832
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
833
|
+
const f = value as FieldDefinitionInput;
|
|
834
|
+
const key = f.key ?? f.name;
|
|
835
|
+
const valueType = f.valueType ?? f.type ?? f.value_type;
|
|
836
|
+
const operators = f.allowedOperators ?? f.operators ?? f.allowed_operators;
|
|
682
837
|
return (
|
|
683
|
-
typeof
|
|
684
|
-
f !== null &&
|
|
685
|
-
typeof f.name === 'string' &&
|
|
838
|
+
typeof key === 'string' &&
|
|
686
839
|
typeof f.label === 'string' &&
|
|
687
|
-
typeof
|
|
688
|
-
Array.isArray(
|
|
840
|
+
typeof valueType === 'string' &&
|
|
841
|
+
Array.isArray(operators)
|
|
689
842
|
);
|
|
690
843
|
}
|
|
691
844
|
|
|
845
|
+
// ============================================================================
|
|
846
|
+
// Canonical Accessors
|
|
847
|
+
// ============================================================================
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* The key a rule filters on.
|
|
851
|
+
*
|
|
852
|
+
* ONE reader for the whole package, so a row persisted before the contract
|
|
853
|
+
* landed (only `fieldPath`) and a row from the current API (`field`) resolve
|
|
854
|
+
* the same way. `field` wins when both are present.
|
|
855
|
+
*/
|
|
856
|
+
export function ruleField(rule: Pick<FilterRule, 'field' | 'fieldPath'>): string {
|
|
857
|
+
return rule.field || rule.fieldPath || '';
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/** The registry key of a field definition, whichever shape it arrived in. */
|
|
861
|
+
export function fieldKey(field: FieldDefinitionInput): string {
|
|
862
|
+
return field.key ?? field.name ?? '';
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/** The value type of a field definition, whichever shape it arrived in. */
|
|
866
|
+
export function fieldValueType(field: FieldDefinitionInput): FieldType {
|
|
867
|
+
return field.valueType ?? field.type ?? field.value_type ?? 'any';
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/** The operators a field accepts, whichever shape it arrived in. */
|
|
871
|
+
export function fieldOperators(field: FieldDefinitionInput): ValidOperators {
|
|
872
|
+
return field.allowedOperators ?? field.operators ?? field.allowed_operators ?? [];
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/** The enum values of a field definition, whichever shape it arrived in. */
|
|
876
|
+
export function fieldEnumValues(field: FieldDefinitionInput): string[] | undefined {
|
|
877
|
+
return field.enumValues ?? field.enum_values;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* Collapse any accepted field-definition shape onto the canonical one.
|
|
882
|
+
*
|
|
883
|
+
* An enum field's values also become `constraints.choices`, so the existing
|
|
884
|
+
* value inputs render a picker for a tag category without knowing about the
|
|
885
|
+
* registry endpoint.
|
|
886
|
+
*/
|
|
887
|
+
export function normalizeFieldDefinition(field: FieldDefinitionInput): FieldDefinition {
|
|
888
|
+
const enumValues = fieldEnumValues(field);
|
|
889
|
+
const constraints = field.constraints;
|
|
890
|
+
const normalized: FieldDefinition = {
|
|
891
|
+
key: fieldKey(field),
|
|
892
|
+
label: field.label ?? fieldKey(field),
|
|
893
|
+
valueType: fieldValueType(field),
|
|
894
|
+
allowedOperators: fieldOperators(field),
|
|
895
|
+
};
|
|
896
|
+
if (field.category !== undefined) normalized.category = field.category;
|
|
897
|
+
if (enumValues !== undefined) normalized.enumValues = enumValues;
|
|
898
|
+
if (field.description !== undefined) normalized.description = field.description;
|
|
899
|
+
if (field.sortable !== undefined) normalized.sortable = field.sortable;
|
|
900
|
+
if (field.searchable !== undefined) normalized.searchable = field.searchable;
|
|
901
|
+
if (field.examples !== undefined) normalized.examples = field.examples;
|
|
902
|
+
if (field.relatedFields !== undefined) normalized.relatedFields = field.relatedFields;
|
|
903
|
+
|
|
904
|
+
if (constraints || (enumValues && enumValues.length > 0)) {
|
|
905
|
+
normalized.constraints = {
|
|
906
|
+
...(constraints ?? {}),
|
|
907
|
+
...(constraints?.choices === undefined && enumValues && enumValues.length > 0
|
|
908
|
+
? { choices: [...enumValues] }
|
|
909
|
+
: {}),
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
return normalized;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/** Normalize a whole registry in one call. */
|
|
916
|
+
export function normalizeFieldRegistry(fields: FieldDefinitionInput[]): FieldDefinition[] {
|
|
917
|
+
return fields.map(normalizeFieldDefinition);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* Collapse a rule node from the wire onto the canonical shape: `fieldPath` is
|
|
922
|
+
* lifted to `field`, `group_logic` to `groupLogic`, recursively.
|
|
923
|
+
*/
|
|
924
|
+
export function normalizeRule(node: unknown): FilterRuleNode {
|
|
925
|
+
if (isRuleGroup(node)) {
|
|
926
|
+
const group = node as RuleGroup & { group_logic?: GroupLogic };
|
|
927
|
+
const normalized: RuleGroup = {
|
|
928
|
+
groupLogic: (group.groupLogic ?? group.group_logic) as GroupLogic,
|
|
929
|
+
children: (group.children ?? []).map(normalizeRule),
|
|
930
|
+
};
|
|
931
|
+
if (group.id !== undefined) normalized.id = group.id;
|
|
932
|
+
if (group.order !== undefined) normalized.order = group.order;
|
|
933
|
+
return normalized;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
const rule = (node ?? {}) as FilterRule & { field_path?: string };
|
|
937
|
+
const normalized: FilterRule = {
|
|
938
|
+
field: rule.field || rule.fieldPath || rule.field_path || '',
|
|
939
|
+
operator: rule.operator,
|
|
940
|
+
};
|
|
941
|
+
if (rule.value !== undefined) normalized.value = rule.value;
|
|
942
|
+
if (rule.negate !== undefined) normalized.negate = rule.negate;
|
|
943
|
+
if (rule.id !== undefined) normalized.id = rule.id;
|
|
944
|
+
if (rule.order !== undefined) normalized.order = rule.order;
|
|
945
|
+
return normalized;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/** Every leaf of a rule tree, in document order. A flat list passes through. */
|
|
949
|
+
export function ruleLeaves(nodes: FilterRuleNode[]): FilterRule[] {
|
|
950
|
+
const out: FilterRule[] = [];
|
|
951
|
+
for (const node of nodes) {
|
|
952
|
+
if (isRuleGroup(node)) out.push(...ruleLeaves(node.children ?? []));
|
|
953
|
+
else out.push(node);
|
|
954
|
+
}
|
|
955
|
+
return out;
|
|
956
|
+
}
|
|
957
|
+
|
|
692
958
|
// ============================================================================
|
|
693
959
|
// Validation Helpers
|
|
694
960
|
// ============================================================================
|
|
@@ -701,22 +967,25 @@ export function getValidOperators(fieldType: FieldType): ValidOperators {
|
|
|
701
967
|
case 'string':
|
|
702
968
|
return [
|
|
703
969
|
'eq', 'ne', 'contains', 'not_contains', 'startswith', 'endswith',
|
|
704
|
-
'matches', 'in', 'not_in', 'isnull', 'isnotnull'
|
|
970
|
+
'matches', 'in', 'not_in', 'exists', 'not_exists', 'isnull', 'isnotnull'
|
|
705
971
|
];
|
|
706
972
|
|
|
707
973
|
case 'number':
|
|
708
974
|
return [
|
|
709
|
-
'eq', 'ne', 'gt', 'lt', 'gte', 'lte',
|
|
710
|
-
'in', 'not_in', 'isnull', 'isnotnull'
|
|
975
|
+
'eq', 'ne', 'gt', 'lt', 'gte', 'lte', 'between',
|
|
976
|
+
'in', 'not_in', 'exists', 'not_exists', 'isnull', 'isnotnull'
|
|
711
977
|
];
|
|
712
978
|
|
|
713
979
|
case 'boolean':
|
|
714
|
-
return ['eq', 'ne', 'is_true', 'is_false', 'isnull', 'isnotnull'];
|
|
980
|
+
return ['eq', 'ne', 'is_true', 'is_false', 'exists', 'not_exists', 'isnull', 'isnotnull'];
|
|
981
|
+
|
|
982
|
+
case 'enum':
|
|
983
|
+
return ['eq', 'ne', 'in', 'not_in', 'exists', 'not_exists'];
|
|
715
984
|
|
|
716
985
|
case 'date':
|
|
717
986
|
return [
|
|
718
|
-
'eq', 'ne', 'gt', 'lt', 'gte', 'lte',
|
|
719
|
-
'isnull', 'isnotnull'
|
|
987
|
+
'eq', 'ne', 'gt', 'lt', 'gte', 'lte', 'between',
|
|
988
|
+
'exists', 'not_exists', 'isnull', 'isnotnull'
|
|
720
989
|
];
|
|
721
990
|
|
|
722
991
|
case 'array':
|
|
@@ -726,17 +995,17 @@ export function getValidOperators(fieldType: FieldType): ValidOperators {
|
|
|
726
995
|
];
|
|
727
996
|
|
|
728
997
|
case 'tag':
|
|
729
|
-
return ['has_tag', 'not_has_tag'];
|
|
998
|
+
return ['has_tag', 'not_has_tag', 'exists', 'not_exists'];
|
|
730
999
|
|
|
731
1000
|
case 'object':
|
|
732
|
-
return ['isnull', 'isnotnull'];
|
|
1001
|
+
return ['exists', 'not_exists', 'isnull', 'isnotnull'];
|
|
733
1002
|
|
|
734
1003
|
case 'any':
|
|
735
1004
|
default:
|
|
736
1005
|
return [
|
|
737
|
-
'eq', 'ne', 'gt', 'lt', 'gte', 'lte',
|
|
1006
|
+
'eq', 'ne', 'gt', 'lt', 'gte', 'lte', 'between',
|
|
738
1007
|
'contains', 'not_contains', 'startswith', 'endswith',
|
|
739
|
-
'in', 'not_in', 'isnull', 'isnotnull'
|
|
1008
|
+
'in', 'not_in', 'exists', 'not_exists', 'isnull', 'isnotnull'
|
|
740
1009
|
];
|
|
741
1010
|
}
|
|
742
1011
|
}
|
|
@@ -758,20 +1027,41 @@ export function isValidOperator(
|
|
|
758
1027
|
export function validateFilterRule(rule: FilterRule): string[] {
|
|
759
1028
|
const errors: string[] = [];
|
|
760
1029
|
|
|
761
|
-
if (!rule
|
|
762
|
-
errors.push('
|
|
1030
|
+
if (!ruleField(rule)) {
|
|
1031
|
+
errors.push('field is required');
|
|
763
1032
|
}
|
|
764
1033
|
|
|
765
1034
|
if (!rule.operator) {
|
|
766
1035
|
errors.push('operator is required');
|
|
767
1036
|
}
|
|
768
1037
|
|
|
769
|
-
//
|
|
770
|
-
|
|
1038
|
+
// Operators that carry no value. exists/not_exists are the backend's own
|
|
1039
|
+
// no-value operators (validate_value_shape → ValueShape.NONE).
|
|
1040
|
+
const nullOps: Operator[] = [
|
|
1041
|
+
'exists',
|
|
1042
|
+
'not_exists',
|
|
1043
|
+
'isnull',
|
|
1044
|
+
'isnotnull',
|
|
1045
|
+
'is_true',
|
|
1046
|
+
'is_false',
|
|
1047
|
+
];
|
|
771
1048
|
if (!nullOps.includes(rule.operator) && rule.value === undefined) {
|
|
772
1049
|
errors.push(`value is required for operator '${rule.operator}'`);
|
|
773
1050
|
}
|
|
774
1051
|
|
|
1052
|
+
if (rule.operator === 'between') {
|
|
1053
|
+
const value = rule.value;
|
|
1054
|
+
if (!Array.isArray(value) || value.length !== 2) {
|
|
1055
|
+
errors.push("operator 'between' requires [min, max]");
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
if ((rule.operator === 'in' || rule.operator === 'not_in') && rule.value !== undefined) {
|
|
1060
|
+
if (!Array.isArray(rule.value) || rule.value.length === 0) {
|
|
1061
|
+
errors.push(`operator '${rule.operator}' requires a non-empty array`);
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
|
|
775
1065
|
return errors;
|
|
776
1066
|
}
|
|
777
1067
|
|