@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,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
|
+
}
|