@startsimpli/funnels 0.1.4 → 0.1.6
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/package.json +9 -31
- package/src/api/README.md +507 -0
- package/src/api/adapter.ts +106 -0
- package/src/api/client.test.ts +640 -0
- package/src/api/client.ts +385 -0
- package/src/api/default-adapter.ts +243 -0
- package/src/api/index.ts +24 -0
- package/src/components/FilterRuleEditor/ARCHITECTURE.md +354 -0
- package/src/components/FilterRuleEditor/FieldSelector.tsx +91 -0
- package/src/components/FilterRuleEditor/FilterRuleEditor.stories.tsx +462 -0
- package/src/components/FilterRuleEditor/FilterRuleEditor.test.tsx +520 -0
- package/src/components/FilterRuleEditor/FilterRuleEditor.tsx +225 -0
- package/src/components/FilterRuleEditor/LogicToggle.tsx +64 -0
- package/src/components/FilterRuleEditor/OperatorSelector.tsx +75 -0
- package/src/components/FilterRuleEditor/README.md +291 -0
- package/src/components/FilterRuleEditor/RuleRow.tsx +246 -0
- package/src/components/FilterRuleEditor/ValueInputs/BooleanValueInput.tsx +54 -0
- package/src/components/FilterRuleEditor/ValueInputs/ChoiceValueInput.tsx +83 -0
- package/src/components/FilterRuleEditor/ValueInputs/DateValueInput.tsx +70 -0
- package/src/components/FilterRuleEditor/ValueInputs/MultiChoiceValueInput.tsx +132 -0
- package/src/components/FilterRuleEditor/ValueInputs/NumberValueInput.tsx +73 -0
- package/src/components/FilterRuleEditor/ValueInputs/TextValueInput.tsx +50 -0
- package/src/components/FilterRuleEditor/ValueInputs/index.ts +12 -0
- package/src/components/FilterRuleEditor/constants.ts +64 -0
- package/src/components/FilterRuleEditor/index.ts +14 -0
- package/src/components/FunnelCard/DESIGN.md +447 -0
- package/src/components/FunnelCard/FunnelCard.stories.tsx +484 -0
- package/src/components/FunnelCard/FunnelCard.test.ts +257 -0
- package/src/components/FunnelCard/FunnelCard.test.tsx +336 -0
- package/src/components/FunnelCard/FunnelCard.tsx +204 -0
- package/src/components/FunnelCard/FunnelStats.tsx +68 -0
- package/src/components/FunnelCard/IMPLEMENTATION_SUMMARY.md +505 -0
- package/src/components/FunnelCard/INSTALLATION.md +304 -0
- package/src/components/FunnelCard/MatchBar.tsx +49 -0
- package/src/components/FunnelCard/README.md +294 -0
- package/src/components/FunnelCard/StageIndicator.tsx +62 -0
- package/src/components/FunnelCard/StatusBadge.tsx +52 -0
- package/src/components/FunnelCard/index.ts +14 -0
- package/src/components/FunnelPreview/EntityCard.tsx +72 -0
- package/src/components/FunnelPreview/FunnelPreview.stories.tsx +227 -0
- package/src/components/FunnelPreview/FunnelPreview.test.tsx +316 -0
- package/src/components/FunnelPreview/FunnelPreview.tsx +249 -0
- package/src/components/FunnelPreview/LoadingPreview.tsx +60 -0
- package/src/components/FunnelPreview/PreviewStats.tsx +78 -0
- package/src/components/FunnelPreview/README.md +337 -0
- package/src/components/FunnelPreview/StageBreakdown.tsx +94 -0
- package/src/components/FunnelPreview/example.tsx +286 -0
- package/src/components/FunnelPreview/index.ts +14 -0
- package/src/components/FunnelRunHistory/COMPONENT_SUMMARY.md +246 -0
- package/src/components/FunnelRunHistory/FunnelRunHistory.stories.tsx +272 -0
- package/src/components/FunnelRunHistory/FunnelRunHistory.test.tsx +323 -0
- package/src/components/FunnelRunHistory/FunnelRunHistory.tsx +329 -0
- package/src/components/FunnelRunHistory/README.md +325 -0
- package/src/components/FunnelRunHistory/RunActions.tsx +168 -0
- package/src/components/FunnelRunHistory/RunDetailsModal.tsx +221 -0
- package/src/components/FunnelRunHistory/RunFilters.tsx +128 -0
- package/src/components/FunnelRunHistory/RunRow.tsx +122 -0
- package/src/components/FunnelRunHistory/RunStatusBadge.tsx +75 -0
- package/src/components/FunnelRunHistory/StageBreakdownList.tsx +110 -0
- package/src/components/FunnelRunHistory/index.ts +51 -0
- package/src/components/FunnelRunHistory/types.ts +40 -0
- package/src/components/FunnelRunHistory/utils.test.ts +126 -0
- package/src/components/FunnelRunHistory/utils.ts +100 -0
- package/src/components/FunnelStageBuilder/AddStageButton.tsx +52 -0
- package/src/components/FunnelStageBuilder/FunnelStageBuilder.css +413 -0
- package/src/components/FunnelStageBuilder/FunnelStageBuilder.stories.tsx +312 -0
- package/src/components/FunnelStageBuilder/FunnelStageBuilder.test.tsx +304 -0
- package/src/components/FunnelStageBuilder/FunnelStageBuilder.tsx +321 -0
- package/src/components/FunnelStageBuilder/README.md +341 -0
- package/src/components/FunnelStageBuilder/StageActions.test.tsx +205 -0
- package/src/components/FunnelStageBuilder/StageActions.tsx +126 -0
- package/src/components/FunnelStageBuilder/StageCard.tsx +202 -0
- package/src/components/FunnelStageBuilder/StageForm.tsx +262 -0
- package/src/components/FunnelStageBuilder/TagInput.test.tsx +178 -0
- package/src/components/FunnelStageBuilder/TagInput.tsx +129 -0
- package/src/components/FunnelStageBuilder/index.ts +21 -0
- package/src/components/FunnelVisualFlow/FlowLegend.tsx +77 -0
- package/{dist/components/index.css → src/components/FunnelVisualFlow/FunnelVisualFlow.css} +89 -13
- package/src/components/FunnelVisualFlow/FunnelVisualFlow.stories.tsx +254 -0
- package/src/components/FunnelVisualFlow/FunnelVisualFlow.test.tsx +208 -0
- package/src/components/FunnelVisualFlow/FunnelVisualFlow.tsx +229 -0
- package/src/components/FunnelVisualFlow/README.md +323 -0
- package/src/components/FunnelVisualFlow/StageNode.tsx +188 -0
- package/src/components/FunnelVisualFlow/example.tsx +227 -0
- package/src/components/FunnelVisualFlow/index.ts +10 -0
- package/src/components/index.ts +102 -0
- package/src/core/README.md +307 -0
- package/src/core/engine.test.ts +1087 -0
- package/src/core/engine.ts +329 -0
- package/src/core/evaluator.example.ts +353 -0
- package/src/core/evaluator.test.ts +639 -0
- package/src/core/evaluator.ts +261 -0
- package/src/core/field-resolver.example.ts +175 -0
- package/src/core/field-resolver.test.ts +541 -0
- package/src/core/field-resolver.ts +247 -0
- package/src/core/index.ts +34 -0
- package/src/core/operators.test.ts +539 -0
- package/src/core/operators.ts +241 -0
- package/src/hooks/index.ts +5 -0
- package/src/hooks/useDebouncedValue.ts +28 -0
- package/src/index.ts +155 -0
- package/src/store/README.md +342 -0
- package/src/store/create-funnel-store.test.ts +686 -0
- package/src/store/create-funnel-store.ts +538 -0
- package/src/store/index.ts +9 -0
- package/src/store/types.ts +294 -0
- package/src/stories/CrossDomain.stories.tsx +149 -0
- package/src/stories/Welcome.stories.tsx +81 -0
- package/src/stories/demo-data/index.ts +3 -0
- package/src/stories/demo-data/investors.ts +216 -0
- package/src/stories/demo-data/leads.ts +223 -0
- package/src/stories/demo-data/recipes.ts +217 -0
- package/src/test/setup.ts +5 -0
- package/src/types/index.ts +843 -0
- package/dist/client-3ESO2NHy.d.ts +0 -310
- package/dist/client-CZu03ACp.d.cts +0 -310
- package/dist/components/index.cjs +0 -3241
- package/dist/components/index.cjs.map +0 -1
- package/dist/components/index.css.map +0 -1
- package/dist/components/index.d.cts +0 -726
- package/dist/components/index.d.ts +0 -726
- package/dist/components/index.js +0 -3194
- package/dist/components/index.js.map +0 -1
- package/dist/core/index.cjs +0 -500
- package/dist/core/index.cjs.map +0 -1
- package/dist/core/index.d.cts +0 -359
- package/dist/core/index.d.ts +0 -359
- package/dist/core/index.js +0 -486
- package/dist/core/index.js.map +0 -1
- package/dist/hooks/index.cjs +0 -20
- package/dist/hooks/index.cjs.map +0 -1
- package/dist/hooks/index.d.cts +0 -11
- package/dist/hooks/index.d.ts +0 -11
- package/dist/hooks/index.js +0 -18
- package/dist/hooks/index.js.map +0 -1
- package/dist/index-BGDEXbuz.d.cts +0 -434
- package/dist/index-BGDEXbuz.d.ts +0 -434
- package/dist/index.cjs +0 -4499
- package/dist/index.cjs.map +0 -1
- package/dist/index.css +0 -198
- package/dist/index.css.map +0 -1
- package/dist/index.d.cts +0 -99
- package/dist/index.d.ts +0 -99
- package/dist/index.js +0 -4421
- package/dist/index.js.map +0 -1
- package/dist/store/index.cjs +0 -389
- package/dist/store/index.cjs.map +0 -1
- package/dist/store/index.d.cts +0 -225
- package/dist/store/index.d.ts +0 -225
- package/dist/store/index.js +0 -386
- package/dist/store/index.js.map +0 -1
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* StatusBadge Component
|
|
3
|
+
*
|
|
4
|
+
* Displays funnel status as a color-coded pill badge.
|
|
5
|
+
*
|
|
6
|
+
* Design Rationale:
|
|
7
|
+
* - Pill shape provides soft, approachable aesthetic
|
|
8
|
+
* - Color coding enables instant status recognition
|
|
9
|
+
* - Uppercase text improves scannability
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { FunnelStatus } from '../../types';
|
|
13
|
+
|
|
14
|
+
interface StatusBadgeProps {
|
|
15
|
+
status: FunnelStatus;
|
|
16
|
+
className?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const statusConfig: Record<FunnelStatus, { color: string; bgColor: string; label: string }> = {
|
|
20
|
+
active: {
|
|
21
|
+
color: 'text-green-800',
|
|
22
|
+
bgColor: 'bg-green-100',
|
|
23
|
+
label: 'ACTIVE',
|
|
24
|
+
},
|
|
25
|
+
draft: {
|
|
26
|
+
color: 'text-yellow-800',
|
|
27
|
+
bgColor: 'bg-yellow-100',
|
|
28
|
+
label: 'DRAFT',
|
|
29
|
+
},
|
|
30
|
+
paused: {
|
|
31
|
+
color: 'text-gray-800',
|
|
32
|
+
bgColor: 'bg-gray-100',
|
|
33
|
+
label: 'PAUSED',
|
|
34
|
+
},
|
|
35
|
+
archived: {
|
|
36
|
+
color: 'text-red-800',
|
|
37
|
+
bgColor: 'bg-red-100',
|
|
38
|
+
label: 'ARCHIVED',
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export function StatusBadge({ status, className = '' }: StatusBadgeProps) {
|
|
43
|
+
const config = statusConfig[status];
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<span
|
|
47
|
+
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${config.bgColor} ${config.color} ${className}`}
|
|
48
|
+
>
|
|
49
|
+
{config.label}
|
|
50
|
+
</span>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FunnelCard Component Exports
|
|
3
|
+
*
|
|
4
|
+
* Main export: FunnelCard
|
|
5
|
+
* Sub-components exported for composition flexibility
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export { FunnelCard } from './FunnelCard';
|
|
9
|
+
export type { FunnelCardProps } from './FunnelCard';
|
|
10
|
+
|
|
11
|
+
export { StatusBadge } from './StatusBadge';
|
|
12
|
+
export { StageIndicator } from './StageIndicator';
|
|
13
|
+
export { MatchBar } from './MatchBar';
|
|
14
|
+
export { FunnelStats } from './FunnelStats';
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EntityCard Component
|
|
3
|
+
*
|
|
4
|
+
* Displays a single entity in the preview list.
|
|
5
|
+
* Supports custom rendering via render prop for entity-specific display.
|
|
6
|
+
*
|
|
7
|
+
* Design Rationale:
|
|
8
|
+
* - Card design provides visual containment
|
|
9
|
+
* - Border and shadow create depth
|
|
10
|
+
* - Rounded corners match overall design system
|
|
11
|
+
* - Render prop pattern keeps component BRUTALLY GENERIC
|
|
12
|
+
*
|
|
13
|
+
* Accessibility:
|
|
14
|
+
* - Semantic article element for content
|
|
15
|
+
* - Focus states for keyboard navigation
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { ReactNode } from 'react';
|
|
19
|
+
|
|
20
|
+
interface EntityCardProps {
|
|
21
|
+
entity: any;
|
|
22
|
+
renderEntity?: (entity: any) => ReactNode;
|
|
23
|
+
className?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Default entity renderer
|
|
28
|
+
* Shows entity name if available, otherwise shows JSON preview
|
|
29
|
+
*/
|
|
30
|
+
function defaultEntityRenderer(entity: any): ReactNode {
|
|
31
|
+
if (entity.name) {
|
|
32
|
+
return (
|
|
33
|
+
<div>
|
|
34
|
+
<div className="font-medium text-gray-900">{entity.name}</div>
|
|
35
|
+
<div className="text-sm text-gray-600 mt-1">
|
|
36
|
+
{Object.keys(entity)
|
|
37
|
+
.filter((key) => key !== 'name')
|
|
38
|
+
.slice(0, 3)
|
|
39
|
+
.map((key) => (
|
|
40
|
+
<span key={key} className="mr-2">
|
|
41
|
+
{key}: {String(entity[key]).slice(0, 20)}
|
|
42
|
+
</span>
|
|
43
|
+
))}
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Fallback: show JSON preview
|
|
50
|
+
return (
|
|
51
|
+
<div className="text-sm text-gray-700 font-mono">
|
|
52
|
+
<pre className="whitespace-pre-wrap break-all">
|
|
53
|
+
{JSON.stringify(entity, null, 2).slice(0, 150)}
|
|
54
|
+
{JSON.stringify(entity, null, 2).length > 150 ? '...' : ''}
|
|
55
|
+
</pre>
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function EntityCard({
|
|
61
|
+
entity,
|
|
62
|
+
renderEntity = defaultEntityRenderer,
|
|
63
|
+
className = '',
|
|
64
|
+
}: EntityCardProps) {
|
|
65
|
+
return (
|
|
66
|
+
<article
|
|
67
|
+
className={`p-3 bg-white border border-gray-200 rounded-lg shadow-sm hover:border-gray-300 transition-colors ${className}`}
|
|
68
|
+
>
|
|
69
|
+
{renderEntity(entity)}
|
|
70
|
+
</article>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FunnelPreview Storybook stories
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
6
|
+
import { FunnelPreview } from './FunnelPreview';
|
|
7
|
+
import { sampleInvestors, investorFunnelExample, sampleRecipes, recipeFunnelExample, sampleLeads, leadFunnelExample } from '../../stories/demo-data';
|
|
8
|
+
|
|
9
|
+
const meta: Meta<typeof FunnelPreview> = {
|
|
10
|
+
title: 'Components/FunnelPreview',
|
|
11
|
+
component: FunnelPreview,
|
|
12
|
+
parameters: {
|
|
13
|
+
layout: 'padded',
|
|
14
|
+
docs: {
|
|
15
|
+
description: {
|
|
16
|
+
component: 'Real-time preview of funnel execution on sample entities. Shows match stats and stage-by-stage breakdown.',
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
tags: ['autodocs'],
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export default meta;
|
|
24
|
+
type Story = StoryObj<typeof FunnelPreview>;
|
|
25
|
+
|
|
26
|
+
// Investor funnel preview
|
|
27
|
+
export const InvestorPreview: Story = {
|
|
28
|
+
args: {
|
|
29
|
+
funnel: investorFunnelExample,
|
|
30
|
+
entities: sampleInvestors,
|
|
31
|
+
renderEntity: (entity) => (
|
|
32
|
+
<div style={{ padding: '0.5rem' }}>
|
|
33
|
+
<div style={{ fontWeight: 600 }}>{entity['firm.name']}</div>
|
|
34
|
+
<div style={{ fontSize: '0.875rem', color: '#666' }}>
|
|
35
|
+
{entity['investor.name']} • {entity['investor.title']}
|
|
36
|
+
</div>
|
|
37
|
+
<div style={{ fontSize: '0.875rem', color: '#666', marginTop: '0.25rem' }}>
|
|
38
|
+
Stage: {entity['firm.stage']} | Score: {entity['investor.score']}
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
),
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// Recipe funnel preview
|
|
46
|
+
export const RecipePreview: Story = {
|
|
47
|
+
args: {
|
|
48
|
+
funnel: recipeFunnelExample,
|
|
49
|
+
entities: sampleRecipes,
|
|
50
|
+
renderEntity: (entity) => (
|
|
51
|
+
<div style={{ padding: '0.5rem' }}>
|
|
52
|
+
<div style={{ fontWeight: 600 }}>{entity['recipe.name']}</div>
|
|
53
|
+
<div style={{ fontSize: '0.875rem', color: '#666' }}>
|
|
54
|
+
{entity['recipe.cuisine']} • {entity['recipe.difficulty']}
|
|
55
|
+
</div>
|
|
56
|
+
<div style={{ fontSize: '0.875rem', color: '#666', marginTop: '0.25rem' }}>
|
|
57
|
+
{entity['recipe.cookTimeMinutes']} min | {entity['recipe.calories']} cal | ⭐ {entity['recipe.rating']}
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
),
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// Lead scoring preview
|
|
65
|
+
export const LeadPreview: Story = {
|
|
66
|
+
args: {
|
|
67
|
+
funnel: leadFunnelExample,
|
|
68
|
+
entities: sampleLeads,
|
|
69
|
+
renderEntity: (entity) => (
|
|
70
|
+
<div style={{ padding: '0.5rem' }}>
|
|
71
|
+
<div style={{ fontWeight: 600 }}>{entity['lead.name']}</div>
|
|
72
|
+
<div style={{ fontSize: '0.875rem', color: '#666' }}>
|
|
73
|
+
{entity['lead.company']} • {entity['lead.industry']}
|
|
74
|
+
</div>
|
|
75
|
+
<div style={{ fontSize: '0.875rem', color: '#666', marginTop: '0.25rem' }}>
|
|
76
|
+
Score: {entity['lead.score']} | Status: {entity['lead.status']}
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
),
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// No matches
|
|
84
|
+
export const NoMatches: Story = {
|
|
85
|
+
args: {
|
|
86
|
+
funnel: {
|
|
87
|
+
...investorFunnelExample,
|
|
88
|
+
stages: [
|
|
89
|
+
{
|
|
90
|
+
id: 'stage-1',
|
|
91
|
+
name: 'Impossible Filter',
|
|
92
|
+
description: 'Filter that matches nothing',
|
|
93
|
+
order: 0,
|
|
94
|
+
rules: [
|
|
95
|
+
{
|
|
96
|
+
field: 'investor.score',
|
|
97
|
+
operator: 'gte',
|
|
98
|
+
value: 1000, // Impossible score
|
|
99
|
+
logicalOperator: 'AND',
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
},
|
|
105
|
+
entities: sampleInvestors,
|
|
106
|
+
renderEntity: (entity) => (
|
|
107
|
+
<div style={{ padding: '0.5rem' }}>
|
|
108
|
+
<div style={{ fontWeight: 600 }}>{entity['firm.name']}</div>
|
|
109
|
+
</div>
|
|
110
|
+
),
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// Large dataset
|
|
115
|
+
export const LargeDataset: Story = {
|
|
116
|
+
args: {
|
|
117
|
+
funnel: investorFunnelExample,
|
|
118
|
+
entities: Array.from({ length: 100 }, (_, i) => ({
|
|
119
|
+
id: `inv-${i}`,
|
|
120
|
+
'firm.name': `Firm ${i}`,
|
|
121
|
+
'firm.stage': i % 3 === 0 ? 'Seed' : i % 3 === 1 ? 'Series A' : 'Pre-Seed',
|
|
122
|
+
'firm.checkSizeMin': 500000 + i * 10000,
|
|
123
|
+
'firm.checkSizeMax': 2000000 + i * 50000,
|
|
124
|
+
'firm.sectors': i % 2 === 0 ? ['SaaS'] : ['AI/ML'],
|
|
125
|
+
'investor.name': `Investor ${i}`,
|
|
126
|
+
'investor.title': i % 2 === 0 ? 'Partner' : 'Principal',
|
|
127
|
+
'investor.score': 70 + (i % 30),
|
|
128
|
+
'firm.lastContactDays': i % 20,
|
|
129
|
+
'firm.portfolioSize': 20 + i,
|
|
130
|
+
})),
|
|
131
|
+
renderEntity: (entity) => (
|
|
132
|
+
<div style={{ padding: '0.5rem' }}>
|
|
133
|
+
<div style={{ fontWeight: 600 }}>{entity['firm.name']}</div>
|
|
134
|
+
<div style={{ fontSize: '0.875rem', color: '#666' }}>
|
|
135
|
+
Score: {entity['investor.score']}
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
),
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// Empty funnel (no stages)
|
|
143
|
+
export const EmptyFunnel: Story = {
|
|
144
|
+
args: {
|
|
145
|
+
funnel: {
|
|
146
|
+
...investorFunnelExample,
|
|
147
|
+
stages: [],
|
|
148
|
+
},
|
|
149
|
+
entities: sampleInvestors,
|
|
150
|
+
renderEntity: (entity) => (
|
|
151
|
+
<div style={{ padding: '0.5rem' }}>
|
|
152
|
+
<div style={{ fontWeight: 600 }}>{entity['firm.name']}</div>
|
|
153
|
+
</div>
|
|
154
|
+
),
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
// Custom entity renderer (minimal)
|
|
159
|
+
export const MinimalEntityRenderer: Story = {
|
|
160
|
+
args: {
|
|
161
|
+
funnel: investorFunnelExample,
|
|
162
|
+
entities: sampleInvestors,
|
|
163
|
+
renderEntity: (entity) => (
|
|
164
|
+
<div style={{ padding: '1rem', textAlign: 'center', fontWeight: 600 }}>
|
|
165
|
+
{entity['firm.name']}
|
|
166
|
+
</div>
|
|
167
|
+
),
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
// Custom entity renderer (detailed)
|
|
172
|
+
export const DetailedEntityRenderer: Story = {
|
|
173
|
+
args: {
|
|
174
|
+
funnel: investorFunnelExample,
|
|
175
|
+
entities: sampleInvestors,
|
|
176
|
+
renderEntity: (entity) => (
|
|
177
|
+
<div style={{ padding: '1rem', border: '2px solid #e5e7eb', borderRadius: '8px' }}>
|
|
178
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
|
|
179
|
+
<div>
|
|
180
|
+
<div style={{ fontSize: '1.125rem', fontWeight: 700, marginBottom: '0.25rem' }}>
|
|
181
|
+
{entity['firm.name']}
|
|
182
|
+
</div>
|
|
183
|
+
<div style={{ fontSize: '0.875rem', color: '#6b7280', marginBottom: '0.5rem' }}>
|
|
184
|
+
{entity['investor.name']} • {entity['investor.title']}
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
<div style={{
|
|
188
|
+
padding: '0.25rem 0.75rem',
|
|
189
|
+
backgroundColor: entity['investor.score'] >= 85 ? '#dcfce7' : '#fef3c7',
|
|
190
|
+
color: entity['investor.score'] >= 85 ? '#166534' : '#92400e',
|
|
191
|
+
borderRadius: '9999px',
|
|
192
|
+
fontSize: '0.875rem',
|
|
193
|
+
fontWeight: 600,
|
|
194
|
+
}}>
|
|
195
|
+
Score: {entity['investor.score']}
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
<div style={{
|
|
199
|
+
display: 'grid',
|
|
200
|
+
gridTemplateColumns: 'repeat(2, 1fr)',
|
|
201
|
+
gap: '0.5rem',
|
|
202
|
+
marginTop: '0.75rem',
|
|
203
|
+
fontSize: '0.875rem',
|
|
204
|
+
}}>
|
|
205
|
+
<div>
|
|
206
|
+
<div style={{ color: '#9ca3af', marginBottom: '0.125rem' }}>Stage</div>
|
|
207
|
+
<div style={{ fontWeight: 600 }}>{entity['firm.stage']}</div>
|
|
208
|
+
</div>
|
|
209
|
+
<div>
|
|
210
|
+
<div style={{ color: '#9ca3af', marginBottom: '0.125rem' }}>Check Size</div>
|
|
211
|
+
<div style={{ fontWeight: 600 }}>
|
|
212
|
+
${(entity['firm.checkSizeMin'] / 1000000).toFixed(1)}M - ${(entity['firm.checkSizeMax'] / 1000000).toFixed(1)}M
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
<div>
|
|
216
|
+
<div style={{ color: '#9ca3af', marginBottom: '0.125rem' }}>Last Contact</div>
|
|
217
|
+
<div style={{ fontWeight: 600 }}>{entity['firm.lastContactDays']} days ago</div>
|
|
218
|
+
</div>
|
|
219
|
+
<div>
|
|
220
|
+
<div style={{ color: '#9ca3af', marginBottom: '0.125rem' }}>Portfolio</div>
|
|
221
|
+
<div style={{ fontWeight: 600 }}>{entity['firm.portfolioSize']} companies</div>
|
|
222
|
+
</div>
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
),
|
|
226
|
+
},
|
|
227
|
+
};
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FunnelPreview Component Tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
6
|
+
import type { Funnel } from '../../types';
|
|
7
|
+
|
|
8
|
+
// Mock entities for testing
|
|
9
|
+
const mockEntities = [
|
|
10
|
+
{ name: 'John Doe', firm: { stage: 'Series A' }, score: 85 },
|
|
11
|
+
{ name: 'Jane Smith', firm: { stage: 'Seed' }, score: 92 },
|
|
12
|
+
{ name: 'Bob Wilson', firm: { stage: 'Series B' }, score: 78 },
|
|
13
|
+
{ name: 'Alice Johnson', firm: { stage: 'Series A' }, score: 88 },
|
|
14
|
+
{ name: 'Charlie Brown', firm: { stage: 'Seed' }, score: 95 },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
// Mock funnel with multiple stages
|
|
18
|
+
const mockFunnel: Funnel = {
|
|
19
|
+
id: 'test-funnel',
|
|
20
|
+
name: 'Test Funnel',
|
|
21
|
+
description: 'A test funnel',
|
|
22
|
+
status: 'active',
|
|
23
|
+
input_type: 'contacts',
|
|
24
|
+
stages: [
|
|
25
|
+
{
|
|
26
|
+
id: 'stage-1',
|
|
27
|
+
order: 0,
|
|
28
|
+
name: 'High Score',
|
|
29
|
+
filter_logic: 'AND',
|
|
30
|
+
rules: [
|
|
31
|
+
{
|
|
32
|
+
field_path: 'score',
|
|
33
|
+
operator: 'gte',
|
|
34
|
+
value: 80,
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
match_action: 'continue',
|
|
38
|
+
no_match_action: 'exclude',
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: 'stage-2',
|
|
42
|
+
order: 1,
|
|
43
|
+
name: 'Series A or Seed',
|
|
44
|
+
filter_logic: 'OR',
|
|
45
|
+
rules: [
|
|
46
|
+
{
|
|
47
|
+
field_path: 'firm.stage',
|
|
48
|
+
operator: 'eq',
|
|
49
|
+
value: 'Series A',
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
field_path: 'firm.stage',
|
|
53
|
+
operator: 'eq',
|
|
54
|
+
value: 'Seed',
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
match_action: 'output',
|
|
58
|
+
no_match_action: 'exclude',
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
created_at: new Date().toISOString(),
|
|
62
|
+
updated_at: new Date().toISOString(),
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
describe('FunnelPreview', () => {
|
|
66
|
+
describe('Preview Computation', () => {
|
|
67
|
+
it('should compute preview with matched entities', async () => {
|
|
68
|
+
const { FunnelEngine } = await import('../../core/engine');
|
|
69
|
+
const engine = new FunnelEngine();
|
|
70
|
+
const result = engine.execute(mockFunnel, mockEntities);
|
|
71
|
+
|
|
72
|
+
expect(result.total_input).toBe(5);
|
|
73
|
+
expect(result.total_matched).toBeGreaterThan(0);
|
|
74
|
+
expect(result.total_excluded).toBeGreaterThan(0);
|
|
75
|
+
expect(result.matched.length).toBe(result.total_matched);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should handle empty entities', async () => {
|
|
79
|
+
const { FunnelEngine } = await import('../../core/engine');
|
|
80
|
+
const engine = new FunnelEngine();
|
|
81
|
+
const result = engine.execute(mockFunnel, []);
|
|
82
|
+
|
|
83
|
+
expect(result.total_input).toBe(0);
|
|
84
|
+
expect(result.total_matched).toBe(0);
|
|
85
|
+
expect(result.total_excluded).toBe(0);
|
|
86
|
+
expect(result.matched).toEqual([]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('should handle empty funnel (no stages)', async () => {
|
|
90
|
+
const emptyFunnel: Funnel = {
|
|
91
|
+
...mockFunnel,
|
|
92
|
+
stages: [],
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const { FunnelEngine } = await import('../../core/engine');
|
|
96
|
+
const engine = new FunnelEngine();
|
|
97
|
+
const result = engine.execute(emptyFunnel, mockEntities);
|
|
98
|
+
|
|
99
|
+
// With no stages, all entities should match
|
|
100
|
+
expect(result.total_matched).toBe(5);
|
|
101
|
+
expect(result.total_excluded).toBe(0);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('Stage Statistics', () => {
|
|
106
|
+
it('should track entities at each stage', async () => {
|
|
107
|
+
const { FunnelEngine } = await import('../../core/engine');
|
|
108
|
+
const engine = new FunnelEngine();
|
|
109
|
+
const result = engine.execute(mockFunnel, mockEntities);
|
|
110
|
+
|
|
111
|
+
// Should have stats for both stages
|
|
112
|
+
expect(result.stage_stats['stage-1']).toBeDefined();
|
|
113
|
+
expect(result.stage_stats['stage-2']).toBeDefined();
|
|
114
|
+
|
|
115
|
+
// Stage 1 should process all entities
|
|
116
|
+
expect(result.stage_stats['stage-1'].input_count).toBe(5);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('should count exclusions per stage', async () => {
|
|
120
|
+
const { FunnelEngine } = await import('../../core/engine');
|
|
121
|
+
const engine = new FunnelEngine();
|
|
122
|
+
const result = engine.execute(mockFunnel, mockEntities);
|
|
123
|
+
|
|
124
|
+
// Sum of excluded across all stages should equal total excluded
|
|
125
|
+
const totalExcludedAcrossStages = Object.values(result.stage_stats).reduce(
|
|
126
|
+
(sum, stats) => sum + stats.excluded_count,
|
|
127
|
+
0
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
expect(totalExcludedAcrossStages).toBeLessThanOrEqual(result.total_excluded);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('Preview Result Conversion', () => {
|
|
135
|
+
it('should calculate match percentage correctly', async () => {
|
|
136
|
+
const { FunnelEngine } = await import('../../core/engine');
|
|
137
|
+
const engine = new FunnelEngine();
|
|
138
|
+
const result = engine.execute(mockFunnel, mockEntities);
|
|
139
|
+
|
|
140
|
+
const total = result.total_matched + result.total_excluded;
|
|
141
|
+
const expectedPercentage =
|
|
142
|
+
total > 0 ? Math.round((result.total_matched / total) * 100) : 0;
|
|
143
|
+
|
|
144
|
+
expect(expectedPercentage).toBeGreaterThanOrEqual(0);
|
|
145
|
+
expect(expectedPercentage).toBeLessThanOrEqual(100);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('should limit preview entities to maxPreviewEntities', async () => {
|
|
149
|
+
const { FunnelEngine } = await import('../../core/engine');
|
|
150
|
+
const engine = new FunnelEngine();
|
|
151
|
+
const result = engine.execute(mockFunnel, mockEntities);
|
|
152
|
+
|
|
153
|
+
const maxPreview = 2;
|
|
154
|
+
const previewEntities = result.matched.slice(0, maxPreview);
|
|
155
|
+
|
|
156
|
+
expect(previewEntities.length).toBeLessThanOrEqual(maxPreview);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe('Multi-stage Funnel', () => {
|
|
161
|
+
it('should process entities through all stages', async () => {
|
|
162
|
+
const multiFunnel: Funnel = {
|
|
163
|
+
...mockFunnel,
|
|
164
|
+
stages: [
|
|
165
|
+
{
|
|
166
|
+
id: 'stage-1',
|
|
167
|
+
order: 0,
|
|
168
|
+
name: 'Stage 1',
|
|
169
|
+
filter_logic: 'AND',
|
|
170
|
+
rules: [{ field_path: 'score', operator: 'gte', value: 80 }],
|
|
171
|
+
match_action: 'continue',
|
|
172
|
+
no_match_action: 'exclude',
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
id: 'stage-2',
|
|
176
|
+
order: 1,
|
|
177
|
+
name: 'Stage 2',
|
|
178
|
+
filter_logic: 'AND',
|
|
179
|
+
rules: [{ field_path: 'score', operator: 'lte', value: 90 }],
|
|
180
|
+
match_action: 'continue',
|
|
181
|
+
no_match_action: 'exclude',
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
id: 'stage-3',
|
|
185
|
+
order: 2,
|
|
186
|
+
name: 'Stage 3',
|
|
187
|
+
filter_logic: 'AND',
|
|
188
|
+
rules: [{ field_path: 'firm.stage', operator: 'eq', value: 'Series A' }],
|
|
189
|
+
match_action: 'output',
|
|
190
|
+
no_match_action: 'exclude',
|
|
191
|
+
},
|
|
192
|
+
],
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const { FunnelEngine } = await import('../../core/engine');
|
|
196
|
+
const engine = new FunnelEngine();
|
|
197
|
+
const result = engine.execute(multiFunnel, mockEntities);
|
|
198
|
+
|
|
199
|
+
// Should have stats for all 3 stages
|
|
200
|
+
expect(Object.keys(result.stage_stats).length).toBe(3);
|
|
201
|
+
expect(result.stage_stats['stage-1']).toBeDefined();
|
|
202
|
+
expect(result.stage_stats['stage-2']).toBeDefined();
|
|
203
|
+
expect(result.stage_stats['stage-3']).toBeDefined();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('should track remaining entities correctly', async () => {
|
|
207
|
+
const { FunnelEngine } = await import('../../core/engine');
|
|
208
|
+
const engine = new FunnelEngine();
|
|
209
|
+
const result = engine.execute(mockFunnel, mockEntities);
|
|
210
|
+
|
|
211
|
+
// For each stage, remaining = input - excluded
|
|
212
|
+
Object.values(result.stage_stats).forEach((stats) => {
|
|
213
|
+
const remaining = stats.input_count - stats.excluded_count;
|
|
214
|
+
expect(remaining).toBeGreaterThanOrEqual(0);
|
|
215
|
+
expect(remaining).toBeLessThanOrEqual(stats.input_count);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
describe('Edge Cases', () => {
|
|
221
|
+
it('should handle 0 entities', async () => {
|
|
222
|
+
const { FunnelEngine } = await import('../../core/engine');
|
|
223
|
+
const engine = new FunnelEngine();
|
|
224
|
+
const result = engine.execute(mockFunnel, []);
|
|
225
|
+
|
|
226
|
+
expect(result.total_input).toBe(0);
|
|
227
|
+
expect(result.total_matched).toBe(0);
|
|
228
|
+
expect(result.total_excluded).toBe(0);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('should handle 100 entities', async () => {
|
|
232
|
+
const largeEntities = Array.from({ length: 100 }, (_, i) => ({
|
|
233
|
+
name: `Entity ${i}`,
|
|
234
|
+
score: 70 + (i % 30),
|
|
235
|
+
firm: { stage: i % 2 === 0 ? 'Series A' : 'Seed' },
|
|
236
|
+
}));
|
|
237
|
+
|
|
238
|
+
const { FunnelEngine } = await import('../../core/engine');
|
|
239
|
+
const engine = new FunnelEngine();
|
|
240
|
+
const result = engine.execute(mockFunnel, largeEntities);
|
|
241
|
+
|
|
242
|
+
expect(result.total_input).toBe(100);
|
|
243
|
+
expect(result.total_matched + result.total_excluded).toBe(100);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('should handle funnel with no rules', async () => {
|
|
247
|
+
const noRulesFunnel: Funnel = {
|
|
248
|
+
...mockFunnel,
|
|
249
|
+
stages: [
|
|
250
|
+
{
|
|
251
|
+
id: 'stage-1',
|
|
252
|
+
order: 0,
|
|
253
|
+
name: 'Pass All',
|
|
254
|
+
filter_logic: 'AND',
|
|
255
|
+
rules: [],
|
|
256
|
+
match_action: 'output',
|
|
257
|
+
no_match_action: 'exclude',
|
|
258
|
+
},
|
|
259
|
+
],
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const { FunnelEngine } = await import('../../core/engine');
|
|
263
|
+
const engine = new FunnelEngine();
|
|
264
|
+
const result = engine.execute(noRulesFunnel, mockEntities);
|
|
265
|
+
|
|
266
|
+
// With no rules, all entities should match
|
|
267
|
+
expect(result.total_matched).toBe(5);
|
|
268
|
+
expect(result.total_excluded).toBe(0);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('should handle custom evaluator', async () => {
|
|
272
|
+
const customFunnel: Funnel = {
|
|
273
|
+
...mockFunnel,
|
|
274
|
+
stages: [
|
|
275
|
+
{
|
|
276
|
+
id: 'stage-1',
|
|
277
|
+
order: 0,
|
|
278
|
+
name: 'Custom',
|
|
279
|
+
filter_logic: 'AND',
|
|
280
|
+
rules: [],
|
|
281
|
+
match_action: 'output',
|
|
282
|
+
no_match_action: 'exclude',
|
|
283
|
+
custom_evaluator: (entity: any) => entity.score > 85,
|
|
284
|
+
},
|
|
285
|
+
],
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const { FunnelEngine } = await import('../../core/engine');
|
|
289
|
+
const engine = new FunnelEngine();
|
|
290
|
+
const result = engine.execute(customFunnel, mockEntities);
|
|
291
|
+
|
|
292
|
+
// Should match entities with score > 85
|
|
293
|
+
expect(result.total_matched).toBe(3); // Jane(92), Alice(88), Charlie(95)
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
describe('Performance', () => {
|
|
298
|
+
it('should complete in reasonable time for 100 entities', async () => {
|
|
299
|
+
const largeEntities = Array.from({ length: 100 }, (_, i) => ({
|
|
300
|
+
name: `Entity ${i}`,
|
|
301
|
+
score: 70 + (i % 30),
|
|
302
|
+
firm: { stage: i % 2 === 0 ? 'Series A' : 'Seed' },
|
|
303
|
+
}));
|
|
304
|
+
|
|
305
|
+
const { FunnelEngine } = await import('../../core/engine');
|
|
306
|
+
const engine = new FunnelEngine();
|
|
307
|
+
|
|
308
|
+
const start = Date.now();
|
|
309
|
+
engine.execute(mockFunnel, largeEntities);
|
|
310
|
+
const duration = Date.now() - start;
|
|
311
|
+
|
|
312
|
+
// Should complete in less than 100ms for 100 entities
|
|
313
|
+
expect(duration).toBeLessThan(100);
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
});
|