@startsimpli/funnels 0.4.13 → 0.4.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/README.md +326 -281
  2. package/package.json +14 -14
  3. package/src/api/client.paths.test.ts +162 -0
  4. package/src/api/client.ts +72 -19
  5. package/src/api/index.ts +11 -0
  6. package/src/api/paths.test.ts +125 -0
  7. package/src/api/paths.ts +146 -0
  8. package/src/components/FilterRuleEditor/FieldSelector.tsx +7 -5
  9. package/src/components/FilterRuleEditor/FilterRuleEditor.stories.tsx +3 -3
  10. package/src/components/FilterRuleEditor/FilterRuleEditor.test.tsx +13 -8
  11. package/src/components/FilterRuleEditor/FilterRuleEditor.tsx +7 -2
  12. package/src/components/FilterRuleEditor/OperatorSelector.tsx +1 -1
  13. package/src/components/FilterRuleEditor/RuleRow.test.tsx +166 -0
  14. package/src/components/FilterRuleEditor/RuleRow.tsx +73 -10
  15. package/src/components/FilterRuleEditor/constants.ts +12 -0
  16. package/src/components/FunnelPreview/example.tsx +15 -15
  17. package/src/components/FunnelStageBuilder/FunnelStageBuilder.stories.tsx +2 -2
  18. package/src/components/FunnelStageBuilder/FunnelStageBuilder.test.tsx +2 -2
  19. package/src/components/FunnelStageBuilder/FunnelStageBuilder.tsx +2 -2
  20. package/src/components/FunnelStageBuilder/StageCard.tsx +2 -2
  21. package/src/components/FunnelStageBuilder/StageForm.tsx +2 -2
  22. package/src/core/evaluator.example.ts +23 -23
  23. package/src/core/evaluator.test.ts +4 -1
  24. package/src/core/evaluator.ts +11 -5
  25. package/src/core/operators.ts +35 -0
  26. package/src/store/create-funnel-store.ts +1 -0
  27. package/src/stories/demo-data/investors.ts +2 -2
  28. package/src/stories/demo-data/leads.ts +2 -2
  29. package/src/stories/demo-data/recipes.ts +2 -2
  30. package/src/types/contract.test.ts +284 -0
  31. package/src/types/index.ts +339 -49
@@ -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
- // Null checks
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
- * Examples:
77
- * - { fieldPath: 'firm.stage', operator: 'eq', value: 'Series A' }
78
- * - { fieldPath: 'recipe.cuisine', operator: 'in', value: ['Italian', 'French'] }
79
- * - { fieldPath: 'contact.email', operator: 'isnotnull', value: null }
80
- * - { fieldPath: 'organization.tags', operator: 'has_tag', value: 'enterprise' }
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
- * Dot-notation path to field
85
- * Examples: 'name', 'firm.stage', 'profile.linkedin_url', 'tags', 'metrics.arr_usd'
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
- fieldPath: string;
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
- * - isnull/isnotnull: null (value ignored)
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: any;
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: 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
- /** Rule field path */
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
- * Describes what fields are available for filtering
482
- * on a given entity type
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
- /** Unique field identifier (dot-notation path) */
486
- name: string;
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
- /** Field data type */
492
- type: FieldType;
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
- /** Valid operators for this field */
495
- operators: ValidOperators;
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: string;
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
- typeof r.fieldPath === 'string' &&
642
- typeof r.operator === 'string' &&
643
- r.value !== undefined
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
- const f = value as FieldDefinition;
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 f === 'object' &&
684
- f !== null &&
685
- typeof f.name === 'string' &&
838
+ typeof key === 'string' &&
686
839
  typeof f.label === 'string' &&
687
- typeof f.type === 'string' &&
688
- Array.isArray(f.operators)
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.fieldPath) {
762
- errors.push('fieldPath is required');
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
- // Value required for most operators
770
- const nullOps = ['isnull', 'isnotnull', 'is_true', 'is_false'];
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