@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.
Files changed (151) hide show
  1. package/package.json +9 -31
  2. package/src/api/README.md +507 -0
  3. package/src/api/adapter.ts +106 -0
  4. package/src/api/client.test.ts +640 -0
  5. package/src/api/client.ts +385 -0
  6. package/src/api/default-adapter.ts +243 -0
  7. package/src/api/index.ts +24 -0
  8. package/src/components/FilterRuleEditor/ARCHITECTURE.md +354 -0
  9. package/src/components/FilterRuleEditor/FieldSelector.tsx +91 -0
  10. package/src/components/FilterRuleEditor/FilterRuleEditor.stories.tsx +462 -0
  11. package/src/components/FilterRuleEditor/FilterRuleEditor.test.tsx +520 -0
  12. package/src/components/FilterRuleEditor/FilterRuleEditor.tsx +225 -0
  13. package/src/components/FilterRuleEditor/LogicToggle.tsx +64 -0
  14. package/src/components/FilterRuleEditor/OperatorSelector.tsx +75 -0
  15. package/src/components/FilterRuleEditor/README.md +291 -0
  16. package/src/components/FilterRuleEditor/RuleRow.tsx +246 -0
  17. package/src/components/FilterRuleEditor/ValueInputs/BooleanValueInput.tsx +54 -0
  18. package/src/components/FilterRuleEditor/ValueInputs/ChoiceValueInput.tsx +83 -0
  19. package/src/components/FilterRuleEditor/ValueInputs/DateValueInput.tsx +70 -0
  20. package/src/components/FilterRuleEditor/ValueInputs/MultiChoiceValueInput.tsx +132 -0
  21. package/src/components/FilterRuleEditor/ValueInputs/NumberValueInput.tsx +73 -0
  22. package/src/components/FilterRuleEditor/ValueInputs/TextValueInput.tsx +50 -0
  23. package/src/components/FilterRuleEditor/ValueInputs/index.ts +12 -0
  24. package/src/components/FilterRuleEditor/constants.ts +64 -0
  25. package/src/components/FilterRuleEditor/index.ts +14 -0
  26. package/src/components/FunnelCard/DESIGN.md +447 -0
  27. package/src/components/FunnelCard/FunnelCard.stories.tsx +484 -0
  28. package/src/components/FunnelCard/FunnelCard.test.ts +257 -0
  29. package/src/components/FunnelCard/FunnelCard.test.tsx +336 -0
  30. package/src/components/FunnelCard/FunnelCard.tsx +204 -0
  31. package/src/components/FunnelCard/FunnelStats.tsx +68 -0
  32. package/src/components/FunnelCard/IMPLEMENTATION_SUMMARY.md +505 -0
  33. package/src/components/FunnelCard/INSTALLATION.md +304 -0
  34. package/src/components/FunnelCard/MatchBar.tsx +49 -0
  35. package/src/components/FunnelCard/README.md +294 -0
  36. package/src/components/FunnelCard/StageIndicator.tsx +62 -0
  37. package/src/components/FunnelCard/StatusBadge.tsx +52 -0
  38. package/src/components/FunnelCard/index.ts +14 -0
  39. package/src/components/FunnelPreview/EntityCard.tsx +72 -0
  40. package/src/components/FunnelPreview/FunnelPreview.stories.tsx +227 -0
  41. package/src/components/FunnelPreview/FunnelPreview.test.tsx +316 -0
  42. package/src/components/FunnelPreview/FunnelPreview.tsx +249 -0
  43. package/src/components/FunnelPreview/LoadingPreview.tsx +60 -0
  44. package/src/components/FunnelPreview/PreviewStats.tsx +78 -0
  45. package/src/components/FunnelPreview/README.md +337 -0
  46. package/src/components/FunnelPreview/StageBreakdown.tsx +94 -0
  47. package/src/components/FunnelPreview/example.tsx +286 -0
  48. package/src/components/FunnelPreview/index.ts +14 -0
  49. package/src/components/FunnelRunHistory/COMPONENT_SUMMARY.md +246 -0
  50. package/src/components/FunnelRunHistory/FunnelRunHistory.stories.tsx +272 -0
  51. package/src/components/FunnelRunHistory/FunnelRunHistory.test.tsx +323 -0
  52. package/src/components/FunnelRunHistory/FunnelRunHistory.tsx +329 -0
  53. package/src/components/FunnelRunHistory/README.md +325 -0
  54. package/src/components/FunnelRunHistory/RunActions.tsx +168 -0
  55. package/src/components/FunnelRunHistory/RunDetailsModal.tsx +221 -0
  56. package/src/components/FunnelRunHistory/RunFilters.tsx +128 -0
  57. package/src/components/FunnelRunHistory/RunRow.tsx +122 -0
  58. package/src/components/FunnelRunHistory/RunStatusBadge.tsx +75 -0
  59. package/src/components/FunnelRunHistory/StageBreakdownList.tsx +110 -0
  60. package/src/components/FunnelRunHistory/index.ts +51 -0
  61. package/src/components/FunnelRunHistory/types.ts +40 -0
  62. package/src/components/FunnelRunHistory/utils.test.ts +126 -0
  63. package/src/components/FunnelRunHistory/utils.ts +100 -0
  64. package/src/components/FunnelStageBuilder/AddStageButton.tsx +52 -0
  65. package/src/components/FunnelStageBuilder/FunnelStageBuilder.css +413 -0
  66. package/src/components/FunnelStageBuilder/FunnelStageBuilder.stories.tsx +312 -0
  67. package/src/components/FunnelStageBuilder/FunnelStageBuilder.test.tsx +304 -0
  68. package/src/components/FunnelStageBuilder/FunnelStageBuilder.tsx +321 -0
  69. package/src/components/FunnelStageBuilder/README.md +341 -0
  70. package/src/components/FunnelStageBuilder/StageActions.test.tsx +205 -0
  71. package/src/components/FunnelStageBuilder/StageActions.tsx +126 -0
  72. package/src/components/FunnelStageBuilder/StageCard.tsx +202 -0
  73. package/src/components/FunnelStageBuilder/StageForm.tsx +262 -0
  74. package/src/components/FunnelStageBuilder/TagInput.test.tsx +178 -0
  75. package/src/components/FunnelStageBuilder/TagInput.tsx +129 -0
  76. package/src/components/FunnelStageBuilder/index.ts +21 -0
  77. package/src/components/FunnelVisualFlow/FlowLegend.tsx +77 -0
  78. package/{dist/components/index.css → src/components/FunnelVisualFlow/FunnelVisualFlow.css} +89 -13
  79. package/src/components/FunnelVisualFlow/FunnelVisualFlow.stories.tsx +254 -0
  80. package/src/components/FunnelVisualFlow/FunnelVisualFlow.test.tsx +208 -0
  81. package/src/components/FunnelVisualFlow/FunnelVisualFlow.tsx +229 -0
  82. package/src/components/FunnelVisualFlow/README.md +323 -0
  83. package/src/components/FunnelVisualFlow/StageNode.tsx +188 -0
  84. package/src/components/FunnelVisualFlow/example.tsx +227 -0
  85. package/src/components/FunnelVisualFlow/index.ts +10 -0
  86. package/src/components/index.ts +102 -0
  87. package/src/core/README.md +307 -0
  88. package/src/core/engine.test.ts +1087 -0
  89. package/src/core/engine.ts +329 -0
  90. package/src/core/evaluator.example.ts +353 -0
  91. package/src/core/evaluator.test.ts +639 -0
  92. package/src/core/evaluator.ts +261 -0
  93. package/src/core/field-resolver.example.ts +175 -0
  94. package/src/core/field-resolver.test.ts +541 -0
  95. package/src/core/field-resolver.ts +247 -0
  96. package/src/core/index.ts +34 -0
  97. package/src/core/operators.test.ts +539 -0
  98. package/src/core/operators.ts +241 -0
  99. package/src/hooks/index.ts +5 -0
  100. package/src/hooks/useDebouncedValue.ts +28 -0
  101. package/src/index.ts +155 -0
  102. package/src/store/README.md +342 -0
  103. package/src/store/create-funnel-store.test.ts +686 -0
  104. package/src/store/create-funnel-store.ts +538 -0
  105. package/src/store/index.ts +9 -0
  106. package/src/store/types.ts +294 -0
  107. package/src/stories/CrossDomain.stories.tsx +149 -0
  108. package/src/stories/Welcome.stories.tsx +81 -0
  109. package/src/stories/demo-data/index.ts +3 -0
  110. package/src/stories/demo-data/investors.ts +216 -0
  111. package/src/stories/demo-data/leads.ts +223 -0
  112. package/src/stories/demo-data/recipes.ts +217 -0
  113. package/src/test/setup.ts +5 -0
  114. package/src/types/index.ts +843 -0
  115. package/dist/client-3ESO2NHy.d.ts +0 -310
  116. package/dist/client-CZu03ACp.d.cts +0 -310
  117. package/dist/components/index.cjs +0 -3241
  118. package/dist/components/index.cjs.map +0 -1
  119. package/dist/components/index.css.map +0 -1
  120. package/dist/components/index.d.cts +0 -726
  121. package/dist/components/index.d.ts +0 -726
  122. package/dist/components/index.js +0 -3194
  123. package/dist/components/index.js.map +0 -1
  124. package/dist/core/index.cjs +0 -500
  125. package/dist/core/index.cjs.map +0 -1
  126. package/dist/core/index.d.cts +0 -359
  127. package/dist/core/index.d.ts +0 -359
  128. package/dist/core/index.js +0 -486
  129. package/dist/core/index.js.map +0 -1
  130. package/dist/hooks/index.cjs +0 -20
  131. package/dist/hooks/index.cjs.map +0 -1
  132. package/dist/hooks/index.d.cts +0 -11
  133. package/dist/hooks/index.d.ts +0 -11
  134. package/dist/hooks/index.js +0 -18
  135. package/dist/hooks/index.js.map +0 -1
  136. package/dist/index-BGDEXbuz.d.cts +0 -434
  137. package/dist/index-BGDEXbuz.d.ts +0 -434
  138. package/dist/index.cjs +0 -4499
  139. package/dist/index.cjs.map +0 -1
  140. package/dist/index.css +0 -198
  141. package/dist/index.css.map +0 -1
  142. package/dist/index.d.cts +0 -99
  143. package/dist/index.d.ts +0 -99
  144. package/dist/index.js +0 -4421
  145. package/dist/index.js.map +0 -1
  146. package/dist/store/index.cjs +0 -389
  147. package/dist/store/index.cjs.map +0 -1
  148. package/dist/store/index.d.cts +0 -225
  149. package/dist/store/index.d.ts +0 -225
  150. package/dist/store/index.js +0 -386
  151. package/dist/store/index.js.map +0 -1
@@ -0,0 +1,262 @@
1
+ /**
2
+ * StageForm - Stage configuration form
3
+ *
4
+ * Features:
5
+ * - Stage name input
6
+ * - Description textarea
7
+ * - Filter logic toggle (AND/OR)
8
+ * - Match/no-match action dropdowns
9
+ * - Tags input
10
+ * - Filter rules editor
11
+ */
12
+
13
+ import React, { useCallback, useState } from 'react';
14
+ import { FunnelStage, FieldDefinition, FilterLogic, MatchAction, NoMatchAction, FilterRule } from '../../types';
15
+ import { StageActions } from './StageActions';
16
+ import { TagInput } from './TagInput';
17
+ import { FilterRuleEditor } from '../FilterRuleEditor/FilterRuleEditor';
18
+
19
+ export interface StageFormProps {
20
+ /** The stage being edited */
21
+ stage: FunnelStage;
22
+
23
+ /** Update stage */
24
+ onUpdate: (stage: FunnelStage) => void;
25
+
26
+ /** Available fields */
27
+ fieldRegistry: FieldDefinition[];
28
+ }
29
+
30
+ /**
31
+ * Debounce helper
32
+ */
33
+ function useDebounce<T extends (...args: any[]) => void>(
34
+ callback: T,
35
+ delay: number
36
+ ): T {
37
+ const [timeoutId, setTimeoutId] = useState<NodeJS.Timeout | null>(null);
38
+
39
+ return useCallback(
40
+ ((...args: any[]) => {
41
+ if (timeoutId) {
42
+ clearTimeout(timeoutId);
43
+ }
44
+
45
+ const newTimeoutId = setTimeout(() => {
46
+ callback(...args);
47
+ }, delay);
48
+
49
+ setTimeoutId(newTimeoutId);
50
+ }) as T,
51
+ [callback, delay, timeoutId]
52
+ );
53
+ }
54
+
55
+ /**
56
+ * StageForm component
57
+ */
58
+ export function StageForm({
59
+ stage,
60
+ onUpdate,
61
+ fieldRegistry,
62
+ }: StageFormProps) {
63
+ // Local state for inputs (debounced updates)
64
+ const [name, setName] = useState(stage.name);
65
+ const [description, setDescription] = useState(stage.description || '');
66
+
67
+ // Debounced update handlers
68
+ const debouncedUpdateName = useDebounce((value: string) => {
69
+ onUpdate({ ...stage, name: value });
70
+ }, 300);
71
+
72
+ const debouncedUpdateDescription = useDebounce((value: string) => {
73
+ onUpdate({ ...stage, description: value });
74
+ }, 500);
75
+
76
+ /**
77
+ * Handle name change
78
+ */
79
+ const handleNameChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
80
+ const value = e.target.value;
81
+ setName(value);
82
+ debouncedUpdateName(value);
83
+ }, [debouncedUpdateName]);
84
+
85
+ /**
86
+ * Handle description change
87
+ */
88
+ const handleDescriptionChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
89
+ const value = e.target.value;
90
+ setDescription(value);
91
+ debouncedUpdateDescription(value);
92
+ }, [debouncedUpdateDescription]);
93
+
94
+ /**
95
+ * Handle filter logic change
96
+ */
97
+ const handleFilterLogicChange = useCallback((logic: FilterLogic) => {
98
+ onUpdate({ ...stage, filter_logic: logic });
99
+ }, [stage, onUpdate]);
100
+
101
+ /**
102
+ * Handle match action change
103
+ */
104
+ const handleMatchActionChange = useCallback((action: MatchAction) => {
105
+ onUpdate({ ...stage, match_action: action });
106
+ }, [stage, onUpdate]);
107
+
108
+ /**
109
+ * Handle no match action change
110
+ */
111
+ const handleNoMatchActionChange = useCallback((action: NoMatchAction) => {
112
+ onUpdate({ ...stage, no_match_action: action });
113
+ }, [stage, onUpdate]);
114
+
115
+ /**
116
+ * Handle match tags change
117
+ */
118
+ const handleMatchTagsChange = useCallback((tags: string[]) => {
119
+ onUpdate({ ...stage, match_tags: tags });
120
+ }, [stage, onUpdate]);
121
+
122
+ /**
123
+ * Handle no match tags change
124
+ */
125
+ const handleNoMatchTagsChange = useCallback((tags: string[]) => {
126
+ onUpdate({ ...stage, no_match_tags: tags });
127
+ }, [stage, onUpdate]);
128
+
129
+ /**
130
+ * Handle filter rules change
131
+ */
132
+ const handleRulesChange = useCallback((rules: FilterRule[]) => {
133
+ onUpdate({ ...stage, rules });
134
+ }, [stage, onUpdate]);
135
+
136
+ return (
137
+ <div className="stage-form">
138
+ {/* Name input */}
139
+ <div className="form-group">
140
+ <label htmlFor={`stage-name-${stage.id}`} className="form-label">
141
+ Stage Name
142
+ </label>
143
+ <input
144
+ id={`stage-name-${stage.id}`}
145
+ type="text"
146
+ value={name}
147
+ onChange={handleNameChange}
148
+ className="form-input"
149
+ placeholder="e.g., High ICP Score"
150
+ required
151
+ />
152
+ </div>
153
+
154
+ {/* Description textarea */}
155
+ <div className="form-group">
156
+ <label htmlFor={`stage-desc-${stage.id}`} className="form-label">
157
+ Description
158
+ </label>
159
+ <textarea
160
+ id={`stage-desc-${stage.id}`}
161
+ value={description}
162
+ onChange={handleDescriptionChange}
163
+ className="form-textarea"
164
+ placeholder="Describe the purpose of this stage..."
165
+ rows={3}
166
+ />
167
+ </div>
168
+
169
+ {/* Filter logic toggle */}
170
+ <div className="form-group">
171
+ <label className="form-label">Filter Logic</label>
172
+ <div className="filter-logic-toggle">
173
+ <button
174
+ type="button"
175
+ onClick={() => handleFilterLogicChange('AND')}
176
+ className={`toggle-button ${stage.filter_logic === 'AND' ? 'active' : ''}`}
177
+ >
178
+ <input
179
+ type="radio"
180
+ name={`filter-logic-${stage.id}`}
181
+ value="AND"
182
+ checked={stage.filter_logic === 'AND'}
183
+ onChange={() => handleFilterLogicChange('AND')}
184
+ className="sr-only"
185
+ />
186
+ AND
187
+ </button>
188
+ <button
189
+ type="button"
190
+ onClick={() => handleFilterLogicChange('OR')}
191
+ className={`toggle-button ${stage.filter_logic === 'OR' ? 'active' : ''}`}
192
+ >
193
+ <input
194
+ type="radio"
195
+ name={`filter-logic-${stage.id}`}
196
+ value="OR"
197
+ checked={stage.filter_logic === 'OR'}
198
+ onChange={() => handleFilterLogicChange('OR')}
199
+ className="sr-only"
200
+ />
201
+ OR
202
+ </button>
203
+ </div>
204
+ <p className="form-hint">
205
+ {stage.filter_logic === 'AND'
206
+ ? 'All rules must match for this stage to pass'
207
+ : 'At least one rule must match for this stage to pass'}
208
+ </p>
209
+ </div>
210
+
211
+ {/* Stage actions */}
212
+ <StageActions
213
+ stage={stage}
214
+ onMatchActionChange={handleMatchActionChange}
215
+ onNoMatchActionChange={handleNoMatchActionChange}
216
+ />
217
+
218
+ {/* Match tags */}
219
+ {(stage.match_action === 'tag' || stage.match_action === 'tag_continue') && (
220
+ <div className="form-group">
221
+ <label className="form-label">Tags on Match</label>
222
+ <TagInput
223
+ tags={stage.match_tags || []}
224
+ onChange={handleMatchTagsChange}
225
+ placeholder="Add tag..."
226
+ />
227
+ <p className="form-hint">
228
+ Tags to add when rules match
229
+ </p>
230
+ </div>
231
+ )}
232
+
233
+ {/* No match tags */}
234
+ {stage.no_match_action === 'tag_exclude' && (
235
+ <div className="form-group">
236
+ <label className="form-label">Tags on No Match</label>
237
+ <TagInput
238
+ tags={stage.no_match_tags || []}
239
+ onChange={handleNoMatchTagsChange}
240
+ placeholder="Add tag..."
241
+ />
242
+ <p className="form-hint">
243
+ Tags to add when rules don't match
244
+ </p>
245
+ </div>
246
+ )}
247
+
248
+ {/* Filter rules section */}
249
+ <div className="form-group">
250
+ <div className="rules-header">
251
+ <label className="form-label">Filter Rules ({stage.rules.length})</label>
252
+ </div>
253
+
254
+ <FilterRuleEditor
255
+ rules={stage.rules}
256
+ onChange={handleRulesChange}
257
+ fieldRegistry={fieldRegistry}
258
+ />
259
+ </div>
260
+ </div>
261
+ );
262
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * TagInput tests
3
+ */
4
+
5
+ import { describe, it, expect, vi } from 'vitest';
6
+ import { render, screen, fireEvent } from '@testing-library/react';
7
+ import { TagInput } from './TagInput';
8
+
9
+ describe('TagInput', () => {
10
+ it('renders with no tags', () => {
11
+ const onChange = vi.fn();
12
+
13
+ render(<TagInput tags={[]} onChange={onChange} />);
14
+
15
+ expect(screen.getByPlaceholderText(/Add tag/i)).toBeInTheDocument();
16
+ });
17
+
18
+ it('renders existing tags', () => {
19
+ const onChange = vi.fn();
20
+
21
+ render(<TagInput tags={['tag1', 'tag2', 'tag3']} onChange={onChange} />);
22
+
23
+ expect(screen.getByText('tag1')).toBeInTheDocument();
24
+ expect(screen.getByText('tag2')).toBeInTheDocument();
25
+ expect(screen.getByText('tag3')).toBeInTheDocument();
26
+ });
27
+
28
+ it('adds tag on Enter key', () => {
29
+ const onChange = vi.fn();
30
+
31
+ render(<TagInput tags={[]} onChange={onChange} />);
32
+
33
+ const input = screen.getByPlaceholderText(/Add tag/i);
34
+ fireEvent.change(input, { target: { value: 'newtag' } });
35
+ fireEvent.keyDown(input, { key: 'Enter' });
36
+
37
+ expect(onChange).toHaveBeenCalledWith(['newtag']);
38
+ });
39
+
40
+ it('adds tag on comma key', () => {
41
+ const onChange = vi.fn();
42
+
43
+ render(<TagInput tags={[]} onChange={onChange} />);
44
+
45
+ const input = screen.getByPlaceholderText(/Add tag/i);
46
+ fireEvent.change(input, { target: { value: 'newtag' } });
47
+ fireEvent.keyDown(input, { key: ',' });
48
+
49
+ expect(onChange).toHaveBeenCalledWith(['newtag']);
50
+ });
51
+
52
+ it('adds tag on blur', () => {
53
+ const onChange = vi.fn();
54
+
55
+ render(<TagInput tags={[]} onChange={onChange} />);
56
+
57
+ const input = screen.getByPlaceholderText(/Add tag/i);
58
+ fireEvent.change(input, { target: { value: 'newtag' } });
59
+ fireEvent.blur(input);
60
+
61
+ expect(onChange).toHaveBeenCalledWith(['newtag']);
62
+ });
63
+
64
+ it('trims whitespace from tags', () => {
65
+ const onChange = vi.fn();
66
+
67
+ render(<TagInput tags={[]} onChange={onChange} />);
68
+
69
+ const input = screen.getByPlaceholderText(/Add tag/i);
70
+ fireEvent.change(input, { target: { value: ' newtag ' } });
71
+ fireEvent.keyDown(input, { key: 'Enter' });
72
+
73
+ expect(onChange).toHaveBeenCalledWith(['newtag']);
74
+ });
75
+
76
+ it('converts tags to lowercase', () => {
77
+ const onChange = vi.fn();
78
+
79
+ render(<TagInput tags={[]} onChange={onChange} />);
80
+
81
+ const input = screen.getByPlaceholderText(/Add tag/i);
82
+ fireEvent.change(input, { target: { value: 'NewTag' } });
83
+ fireEvent.keyDown(input, { key: 'Enter' });
84
+
85
+ expect(onChange).toHaveBeenCalledWith(['newtag']);
86
+ });
87
+
88
+ it('prevents duplicate tags', () => {
89
+ const onChange = vi.fn();
90
+
91
+ render(<TagInput tags={['existing']} onChange={onChange} />);
92
+
93
+ const input = screen.getByRole('textbox');
94
+ fireEvent.change(input, { target: { value: 'existing' } });
95
+ fireEvent.keyDown(input, { key: 'Enter' });
96
+
97
+ expect(onChange).not.toHaveBeenCalled();
98
+ });
99
+
100
+ it('ignores empty tags', () => {
101
+ const onChange = vi.fn();
102
+
103
+ render(<TagInput tags={[]} onChange={onChange} />);
104
+
105
+ const input = screen.getByPlaceholderText(/Add tag/i);
106
+ fireEvent.change(input, { target: { value: ' ' } });
107
+ fireEvent.keyDown(input, { key: 'Enter' });
108
+
109
+ expect(onChange).not.toHaveBeenCalled();
110
+ });
111
+
112
+ it('removes tag when remove button clicked', () => {
113
+ const onChange = vi.fn();
114
+
115
+ render(<TagInput tags={['tag1', 'tag2', 'tag3']} onChange={onChange} />);
116
+
117
+ // Click remove button for tag2
118
+ const removeButtons = screen.getAllByLabelText(/Remove tag/i);
119
+ fireEvent.click(removeButtons[1]);
120
+
121
+ expect(onChange).toHaveBeenCalledWith(['tag1', 'tag3']);
122
+ });
123
+
124
+ it('removes last tag on Backspace when input is empty', () => {
125
+ const onChange = vi.fn();
126
+
127
+ render(<TagInput tags={['tag1', 'tag2']} onChange={onChange} />);
128
+
129
+ const input = screen.getByRole('textbox');
130
+ fireEvent.keyDown(input, { key: 'Backspace' });
131
+
132
+ expect(onChange).toHaveBeenCalledWith(['tag1']);
133
+ });
134
+
135
+ it('does not remove tag on Backspace when input has value', () => {
136
+ const onChange = vi.fn();
137
+
138
+ render(<TagInput tags={['tag1', 'tag2']} onChange={onChange} />);
139
+
140
+ const input = screen.getByRole('textbox');
141
+ fireEvent.change(input, { target: { value: 'text' } });
142
+ fireEvent.keyDown(input, { key: 'Backspace' });
143
+
144
+ expect(onChange).not.toHaveBeenCalled();
145
+ });
146
+
147
+ it('clears input after adding tag', () => {
148
+ const onChange = vi.fn();
149
+
150
+ render(<TagInput tags={[]} onChange={onChange} />);
151
+
152
+ const input = screen.getByPlaceholderText(/Add tag/i) as HTMLInputElement;
153
+ fireEvent.change(input, { target: { value: 'newtag' } });
154
+ fireEvent.keyDown(input, { key: 'Enter' });
155
+
156
+ expect(input.value).toBe('');
157
+ });
158
+
159
+ it('applies custom className', () => {
160
+ const onChange = vi.fn();
161
+
162
+ const { container } = render(
163
+ <TagInput tags={[]} onChange={onChange} className="custom-class" />
164
+ );
165
+
166
+ expect(container.querySelector('.custom-class')).toBeInTheDocument();
167
+ });
168
+
169
+ it('uses custom placeholder', () => {
170
+ const onChange = vi.fn();
171
+
172
+ render(
173
+ <TagInput tags={[]} onChange={onChange} placeholder="Custom placeholder" />
174
+ );
175
+
176
+ expect(screen.getByPlaceholderText('Custom placeholder')).toBeInTheDocument();
177
+ });
178
+ });
@@ -0,0 +1,129 @@
1
+ /**
2
+ * TagInput - Multi-tag input component
3
+ *
4
+ * Features:
5
+ * - Add tags with Enter or comma
6
+ * - Remove tags with click
7
+ * - Visual tag chips
8
+ * - Duplicate prevention
9
+ */
10
+
11
+ import React, { useState, useCallback, KeyboardEvent } from 'react';
12
+
13
+ export interface TagInputProps {
14
+ /** Current tags */
15
+ tags: string[];
16
+
17
+ /** Change handler */
18
+ onChange: (tags: string[]) => void;
19
+
20
+ /** Input placeholder */
21
+ placeholder?: string;
22
+
23
+ /** Optional CSS class */
24
+ className?: string;
25
+ }
26
+
27
+ /**
28
+ * TagInput component
29
+ */
30
+ export function TagInput({
31
+ tags,
32
+ onChange,
33
+ placeholder = 'Add tag...',
34
+ className = '',
35
+ }: TagInputProps) {
36
+ const [inputValue, setInputValue] = useState('');
37
+
38
+ /**
39
+ * Add tag
40
+ */
41
+ const addTag = useCallback((tag: string) => {
42
+ const trimmed = tag.trim().toLowerCase();
43
+
44
+ if (!trimmed) {
45
+ return;
46
+ }
47
+
48
+ // Prevent duplicates
49
+ if (tags.includes(trimmed)) {
50
+ return;
51
+ }
52
+
53
+ onChange([...tags, trimmed]);
54
+ setInputValue('');
55
+ }, [tags, onChange]);
56
+
57
+ /**
58
+ * Remove tag
59
+ */
60
+ const removeTag = useCallback((index: number) => {
61
+ onChange(tags.filter((_, i) => i !== index));
62
+ }, [tags, onChange]);
63
+
64
+ /**
65
+ * Handle input key down
66
+ */
67
+ const handleKeyDown = useCallback((e: KeyboardEvent<HTMLInputElement>) => {
68
+ if (e.key === 'Enter' || e.key === ',') {
69
+ e.preventDefault();
70
+ addTag(inputValue);
71
+ } else if (e.key === 'Backspace' && !inputValue && tags.length > 0) {
72
+ // Remove last tag on backspace if input is empty
73
+ removeTag(tags.length - 1);
74
+ }
75
+ }, [inputValue, tags, addTag, removeTag]);
76
+
77
+ /**
78
+ * Handle input blur
79
+ */
80
+ const handleBlur = useCallback(() => {
81
+ if (inputValue) {
82
+ addTag(inputValue);
83
+ }
84
+ }, [inputValue, addTag]);
85
+
86
+ return (
87
+ <div className={`tag-input ${className}`}>
88
+ <div className="tag-input-container">
89
+ {/* Tag chips */}
90
+ {tags.map((tag, index) => (
91
+ <div key={index} className="tag-chip">
92
+ <span className="tag-text">{tag}</span>
93
+ <button
94
+ type="button"
95
+ onClick={() => removeTag(index)}
96
+ className="tag-remove"
97
+ aria-label={`Remove tag ${tag}`}
98
+ >
99
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
100
+ <path
101
+ d="M4 4l6 6M10 4l-6 6"
102
+ stroke="currentColor"
103
+ strokeWidth="1.5"
104
+ strokeLinecap="round"
105
+ />
106
+ </svg>
107
+ </button>
108
+ </div>
109
+ ))}
110
+
111
+ {/* Input */}
112
+ <input
113
+ type="text"
114
+ value={inputValue}
115
+ onChange={(e) => setInputValue(e.target.value)}
116
+ onKeyDown={handleKeyDown}
117
+ onBlur={handleBlur}
118
+ placeholder={tags.length === 0 ? placeholder : ''}
119
+ className="tag-input-field"
120
+ />
121
+ </div>
122
+
123
+ {/* Hint */}
124
+ <p className="tag-input-hint">
125
+ Press Enter or comma to add tags
126
+ </p>
127
+ </div>
128
+ );
129
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * FunnelStageBuilder component exports
3
+ */
4
+
5
+ export { FunnelStageBuilder } from './FunnelStageBuilder';
6
+ export type { FunnelStageBuilderProps } from './FunnelStageBuilder';
7
+
8
+ export { StageCard } from './StageCard';
9
+ export type { StageCardProps } from './StageCard';
10
+
11
+ export { StageForm } from './StageForm';
12
+ export type { StageFormProps } from './StageForm';
13
+
14
+ export { StageActions } from './StageActions';
15
+ export type { StageActionsProps } from './StageActions';
16
+
17
+ export { TagInput } from './TagInput';
18
+ export type { TagInputProps } from './TagInput';
19
+
20
+ export { AddStageButton } from './AddStageButton';
21
+ export type { AddStageButtonProps } from './AddStageButton';
@@ -0,0 +1,77 @@
1
+ /**
2
+ * FlowLegend Component
3
+ *
4
+ * Legend explaining the color coding of funnel stages.
5
+ *
6
+ * Design:
7
+ * - Minimal, non-intrusive panel in bottom-right
8
+ * - Color swatches with labels
9
+ * - Collapsible to save space
10
+ *
11
+ * Colors:
12
+ * - Blue: Continue to next stage
13
+ * - Red: Exclude entities
14
+ * - Yellow: Tag entities
15
+ * - Green: Final output
16
+ */
17
+
18
+ import React, { useState } from 'react';
19
+ import { Panel } from '@xyflow/react';
20
+
21
+ export function FlowLegend() {
22
+ const [isExpanded, setIsExpanded] = useState(true);
23
+
24
+ const legendItems = [
25
+ { color: '#3b82f6', label: 'Continue' },
26
+ { color: '#ef4444', label: 'Exclude' },
27
+ { color: '#eab308', label: 'Tag' },
28
+ { color: '#22c55e', label: 'Output' },
29
+ ];
30
+
31
+ return (
32
+ <Panel position="bottom-right" className="flow-legend-panel">
33
+ <div className="flow-legend">
34
+ <button
35
+ className="legend-toggle"
36
+ onClick={() => setIsExpanded(!isExpanded)}
37
+ aria-label={isExpanded ? 'Collapse legend' : 'Expand legend'}
38
+ >
39
+ <span className="legend-title">Legend</span>
40
+ <svg
41
+ className={`legend-chevron ${isExpanded ? 'expanded' : ''}`}
42
+ width="12"
43
+ height="12"
44
+ viewBox="0 0 12 12"
45
+ fill="none"
46
+ xmlns="http://www.w3.org/2000/svg"
47
+ >
48
+ <path
49
+ d="M3 4.5L6 7.5L9 4.5"
50
+ stroke="currentColor"
51
+ strokeWidth="1.5"
52
+ strokeLinecap="round"
53
+ strokeLinejoin="round"
54
+ />
55
+ </svg>
56
+ </button>
57
+
58
+ {isExpanded && (
59
+ <div className="legend-items">
60
+ {legendItems.map((item) => (
61
+ <div key={item.label} className="legend-item">
62
+ <div
63
+ className="legend-color"
64
+ style={{
65
+ backgroundColor: item.color,
66
+ border: `2px solid ${item.color}`,
67
+ }}
68
+ />
69
+ <span className="legend-label">{item.label}</span>
70
+ </div>
71
+ ))}
72
+ </div>
73
+ )}
74
+ </div>
75
+ </Panel>
76
+ );
77
+ }