@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.
Files changed (37) hide show
  1. package/README.md +326 -281
  2. package/package.json +24 -16
  3. package/src/api/client.paths.test.ts +175 -0
  4. package/src/api/client.ts +78 -19
  5. package/src/api/http-adapter.test.ts +68 -0
  6. package/src/api/http-adapter.ts +48 -0
  7. package/src/api/index.ts +16 -0
  8. package/src/api/paths.test.ts +138 -0
  9. package/src/api/paths.ts +192 -0
  10. package/src/components/FilterRuleEditor/FieldSelector.tsx +7 -5
  11. package/src/components/FilterRuleEditor/FilterRuleEditor.stories.tsx +3 -3
  12. package/src/components/FilterRuleEditor/FilterRuleEditor.test.tsx +13 -8
  13. package/src/components/FilterRuleEditor/FilterRuleEditor.tsx +7 -2
  14. package/src/components/FilterRuleEditor/OperatorSelector.tsx +1 -1
  15. package/src/components/FilterRuleEditor/RuleRow.test.tsx +166 -0
  16. package/src/components/FilterRuleEditor/RuleRow.tsx +73 -10
  17. package/src/components/FilterRuleEditor/constants.ts +12 -0
  18. package/src/components/FunnelPreview/example.tsx +15 -15
  19. package/src/components/FunnelStageBuilder/FunnelStageBuilder.stories.tsx +2 -2
  20. package/src/components/FunnelStageBuilder/FunnelStageBuilder.test.tsx +2 -2
  21. package/src/components/FunnelStageBuilder/FunnelStageBuilder.tsx +2 -2
  22. package/src/components/FunnelStageBuilder/StageCard.tsx +2 -2
  23. package/src/components/FunnelStageBuilder/StageForm.tsx +2 -2
  24. package/src/core/evaluator.example.ts +23 -23
  25. package/src/core/evaluator.test.ts +4 -1
  26. package/src/core/evaluator.ts +11 -5
  27. package/src/core/operators.ts +35 -0
  28. package/src/page/FunnelsPage.test.tsx +383 -0
  29. package/src/page/FunnelsPage.tsx +739 -0
  30. package/src/page/index.ts +24 -0
  31. package/src/page/types.ts +102 -0
  32. package/src/store/create-funnel-store.ts +1 -0
  33. package/src/stories/demo-data/investors.ts +2 -2
  34. package/src/stories/demo-data/leads.ts +2 -2
  35. package/src/stories/demo-data/recipes.ts +2 -2
  36. package/src/types/contract.test.ts +284 -0
  37. package/src/types/index.ts +339 -49
@@ -0,0 +1,24 @@
1
+ /**
2
+ * @startsimpli/funnels/page — the whole Funnels screen, mounted not rebuilt.
3
+ *
4
+ * A SUBPATH AND NOT THE ROOT BARREL, deliberately (the same reasoning
5
+ * `@startsimpli/ui/foundry` writes down for itself). This module is the one
6
+ * thing in the package that needs `@startsimpli/ui` — for UnifiedTable, and so
7
+ * for repo rule #8's server-side paging. raise and market import
8
+ * `@startsimpli/funnels` for the engine and the rule editor and must not be
9
+ * made to install a UI library to get them, so the import lives behind
10
+ *
11
+ * import { FunnelsPage } from '@startsimpli/funnels/page'
12
+ *
13
+ * and `@startsimpli/ui` is an OPTIONAL peer dependency.
14
+ *
15
+ * bd startsim-em5mn.
16
+ */
17
+ export { FunnelsPage } from './FunnelsPage';
18
+ export type {
19
+ FunnelsPageProps,
20
+ FunnelListRow,
21
+ FunnelResultRow,
22
+ FunnelListQuery,
23
+ StageReached,
24
+ } from './types';
@@ -0,0 +1,102 @@
1
+ /**
2
+ * What the FunnelsPage composer is handed, and the row shapes it renders.
3
+ *
4
+ * The wire shapes below are DECLARED HERE rather than in `../types` on purpose.
5
+ * `../types` is the funnel CONTRACT — the shapes paired with
6
+ * `@startsimpli/api`'s by `contract.test.ts`, changed only in lockstep. What a
7
+ * LIST endpoint adds on top (`stage_count`, `total_runs`, `last_run_*`) and
8
+ * what `FunnelResultSerializer` emits per row are presentation payloads: this
9
+ * page is their only reader, so widening the contract for them would make two
10
+ * packages agree about something neither of them models.
11
+ *
12
+ * bd startsim-em5mn.
13
+ */
14
+ import type { FunnelApiClient } from '../api/client';
15
+ import type { Funnel, FunnelStatus } from '../types';
16
+
17
+ /**
18
+ * A funnel as a LIST answers it: the contract shape plus the rollups
19
+ * `FunnelSerializer` computes (`stage_count`, `total_runs`, `last_run_at`,
20
+ * `last_run_id`, `last_run_matched`, `created_by_name`), camelized by the
21
+ * adapter.
22
+ */
23
+ export interface FunnelListRow extends Funnel {
24
+ stageCount?: number;
25
+ totalRuns?: number;
26
+ lastRunAt?: string | null;
27
+ lastRunId?: string | null;
28
+ lastRunMatched?: number | null;
29
+ createdByName?: string | null;
30
+ }
31
+
32
+ /** Where a result stopped, named from what the run RECORDED (breakdown.py). */
33
+ export interface StageReached {
34
+ id: string;
35
+ name: string | null;
36
+ order: number | null;
37
+ }
38
+
39
+ /**
40
+ * One row of a run's results, as the control plane serves them.
41
+ *
42
+ * `context` is free-form and reaches the browser through an adapter that
43
+ * camelizes KEYS, so it is rendered as an opaque count rather than as fields:
44
+ * a key the executor wrote as `reached_stage_id` arrives as `reachedStageId`,
45
+ * and presenting that as though it were the stored name would be a lie about
46
+ * the record. `stageReached` is the answer to the same question, computed
47
+ * server-side, and is what this page reads.
48
+ */
49
+ export interface FunnelResultRow {
50
+ id: string;
51
+ entityId: string;
52
+ entityType: string;
53
+ matched: boolean;
54
+ excludedAtStage: string | null;
55
+ stageReached: StageReached | null;
56
+ exclusionReason?: string;
57
+ context?: Record<string, unknown>;
58
+ accumulatedTags?: string[];
59
+ createdAt: string;
60
+ }
61
+
62
+ /** The server-side narrowings the funnel list supports. */
63
+ export interface FunnelListQuery {
64
+ status: FunnelStatus | '';
65
+ search: string;
66
+ page: number;
67
+ }
68
+
69
+ export interface FunnelsPageProps {
70
+ /**
71
+ * The funnel client, already bound to the path family of the surface that is
72
+ * mounting this page — `foundryTenantFunnelPaths(slug)` on the control plane,
73
+ * `proxiedCentralFunnelPaths('/central-api')` inside a fork. The page never
74
+ * builds a URL of its own; that is the whole point of the injection.
75
+ */
76
+ client: FunnelApiClient;
77
+
78
+ /**
79
+ * Where the rule vocabulary comes from. SEPARATE FROM `client` because the
80
+ * two are not on the same surface: `/fields/` is mounted ONLY on central
81
+ * (`/api/v1/funnels/fields/`, scoped to the caller's company), and the
82
+ * control plane's `funnels/fields/` is shadowed by its `funnels/<id>/`
83
+ * action and 404s. See the note on `foundryTenantFunnelPaths`.
84
+ *
85
+ * Defaults to `client`, which is right for any surface that does serve the
86
+ * endpoint under its own prefix.
87
+ */
88
+ fieldsClient?: FunnelApiClient;
89
+
90
+ /**
91
+ * Whether this viewer may create, edit and run funnels (`manage_funnels`).
92
+ * Read-only viewers (`view_funnels`) still get the list, the history and the
93
+ * results. The server enforces this either way — this only keeps the page
94
+ * from offering a button that will 403.
95
+ */
96
+ canManage?: boolean;
97
+
98
+ /** Rows per page for both tables. */
99
+ pageSize?: number;
100
+
101
+ className?: string;
102
+ }
@@ -204,6 +204,7 @@ export function createFunnelStore<TEntity = any>(
204
204
  name: `${funnel.name} (Copy)`,
205
205
  description: funnel.description,
206
206
  status: 'draft', // Always create as draft
207
+ entityType: funnel.entityType,
207
208
  inputType: funnel.inputType,
208
209
  stages: funnel.stages.map((stage, index) => ({
209
210
  ...stage,
@@ -1,6 +1,6 @@
1
- import type { FieldDefinition } from '../../types';
1
+ import type { FieldDefinitionInput } from '../../types';
2
2
 
3
- export const investorFields: FieldDefinition[] = [
3
+ export const investorFields: FieldDefinitionInput[] = [
4
4
  {
5
5
  name: 'firm.name',
6
6
  type: 'text',
@@ -1,6 +1,6 @@
1
- import type { FieldDefinition } from '../../types';
1
+ import type { FieldDefinitionInput } from '../../types';
2
2
 
3
- export const leadFields: FieldDefinition[] = [
3
+ export const leadFields: FieldDefinitionInput[] = [
4
4
  {
5
5
  name: 'lead.name',
6
6
  type: 'text',
@@ -1,6 +1,6 @@
1
- import type { FieldDefinition } from '../../types';
1
+ import type { FieldDefinitionInput } from '../../types';
2
2
 
3
- export const recipeFields: FieldDefinition[] = [
3
+ export const recipeFields: FieldDefinitionInput[] = [
4
4
  {
5
5
  name: 'recipe.name',
6
6
  type: 'text',
@@ -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
+ });