@runtypelabs/react-flow 0.1.0

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.
@@ -0,0 +1,264 @@
1
+ import React, { memo, useCallback, useState } from 'react'
2
+ import type { NodeProps } from '@xyflow/react'
3
+ import { Position } from '@xyflow/react'
4
+ import { BaseNode, GitBranchIcon, NODE_HEADER_COLORS } from './BaseNode'
5
+ import type { RuntypeNodeData, ConditionalStepConfig, FlowStep } from '../../types'
6
+
7
+ // ============================================================================
8
+ // Styles
9
+ // ============================================================================
10
+
11
+ const styles = {
12
+ field: {
13
+ marginBottom: '12px',
14
+ },
15
+ label: {
16
+ display: 'block',
17
+ fontSize: '11px',
18
+ fontWeight: 600,
19
+ color: '#6b7280',
20
+ marginBottom: '4px',
21
+ textTransform: 'uppercase' as const,
22
+ letterSpacing: '0.03em',
23
+ },
24
+ input: {
25
+ width: '100%',
26
+ padding: '8px 10px',
27
+ fontSize: '12px',
28
+ border: '1px solid #e5e7eb',
29
+ borderRadius: '6px',
30
+ backgroundColor: '#f9fafb',
31
+ color: '#1f2937',
32
+ outline: 'none',
33
+ transition: 'border-color 0.15s ease, box-shadow 0.15s ease',
34
+ },
35
+ conditionArea: {
36
+ width: '100%',
37
+ padding: '10px',
38
+ fontSize: '12px',
39
+ border: '1px solid #e5e7eb',
40
+ borderRadius: '6px',
41
+ backgroundColor: '#fef7ee',
42
+ color: '#92400e',
43
+ outline: 'none',
44
+ resize: 'vertical' as const,
45
+ minHeight: '60px',
46
+ fontFamily: '"Fira Code", "Monaco", "Consolas", monospace',
47
+ lineHeight: 1.5,
48
+ },
49
+ branchInfo: {
50
+ display: 'flex',
51
+ gap: '12px',
52
+ marginTop: '8px',
53
+ },
54
+ branchCard: (type: 'true' | 'false') => ({
55
+ flex: 1,
56
+ padding: '10px',
57
+ borderRadius: '8px',
58
+ backgroundColor: type === 'true' ? '#d1fae5' : '#fee2e2',
59
+ border: `1px solid ${type === 'true' ? '#a7f3d0' : '#fecaca'}`,
60
+ }),
61
+ branchLabel: (type: 'true' | 'false') => ({
62
+ fontSize: '10px',
63
+ fontWeight: 700,
64
+ textTransform: 'uppercase' as const,
65
+ letterSpacing: '0.05em',
66
+ color: type === 'true' ? '#059669' : '#dc2626',
67
+ marginBottom: '4px',
68
+ }),
69
+ branchCount: {
70
+ fontSize: '18px',
71
+ fontWeight: 700,
72
+ color: '#1f2937',
73
+ },
74
+ branchCountLabel: {
75
+ fontSize: '10px',
76
+ color: '#6b7280',
77
+ },
78
+ helpText: {
79
+ fontSize: '10px',
80
+ color: '#9ca3af',
81
+ marginTop: '4px',
82
+ fontStyle: 'italic' as const,
83
+ },
84
+ }
85
+
86
+ // ============================================================================
87
+ // Conditional Node Component
88
+ // ============================================================================
89
+
90
+ export const ConditionalNode = memo(function ConditionalNode(props: NodeProps) {
91
+ const { data, selected, id } = props as NodeProps & { data: RuntypeNodeData }
92
+ const { step, onChange } = data
93
+ const config = step.config as ConditionalStepConfig
94
+
95
+ const [isExpanded, setIsExpanded] = useState(false)
96
+
97
+ const handleChange = useCallback(
98
+ (field: keyof ConditionalStepConfig, value: unknown) => {
99
+ onChange?.(id, {
100
+ config: {
101
+ ...config,
102
+ [field]: value,
103
+ },
104
+ } as Partial<FlowStep>)
105
+ },
106
+ [id, config, onChange]
107
+ )
108
+
109
+ const handleNameChange = useCallback(
110
+ (e: React.ChangeEvent<HTMLInputElement>) => {
111
+ onChange?.(id, { name: e.target.value })
112
+ },
113
+ [id, onChange]
114
+ )
115
+
116
+ const trueStepsCount = config.trueSteps?.length || 0
117
+ const falseStepsCount = config.falseSteps?.length || 0
118
+
119
+ return (
120
+ <BaseNode
121
+ data={data}
122
+ selected={selected}
123
+ id={id}
124
+ typeLabel="Conditional"
125
+ icon={<GitBranchIcon />}
126
+ headerColor={NODE_HEADER_COLORS.conditional}
127
+ showSourceHandle={false}
128
+ additionalSourceHandles={[
129
+ {
130
+ id: 'true',
131
+ position: Position.Right,
132
+ label: 'True',
133
+ color: '#22c55e',
134
+ style: { top: '40%' },
135
+ },
136
+ {
137
+ id: 'false',
138
+ position: Position.Right,
139
+ label: 'False',
140
+ color: '#ef4444',
141
+ style: { top: '60%' },
142
+ },
143
+ ]}
144
+ >
145
+ <div style={styles.field}>
146
+ <label style={styles.label}>Step Name</label>
147
+ <input
148
+ style={styles.input}
149
+ value={step.name}
150
+ onChange={handleNameChange}
151
+ placeholder="Enter step name"
152
+ onKeyDown={(e) => e.stopPropagation()}
153
+ />
154
+ </div>
155
+
156
+ <div style={styles.field}>
157
+ <label style={styles.label}>Condition (JavaScript)</label>
158
+ <textarea
159
+ style={styles.conditionArea}
160
+ value={config.condition || ''}
161
+ onChange={(e) => handleChange('condition', e.target.value)}
162
+ placeholder="user_type === 'premium'"
163
+ spellCheck={false}
164
+ onKeyDown={(e) => e.stopPropagation()}
165
+ />
166
+ <div style={styles.helpText}>
167
+ Access variables directly: <code>variable_name</code>, <code>_record.metadata.field</code>
168
+ </div>
169
+ </div>
170
+
171
+ <div style={styles.branchInfo}>
172
+ <div style={styles.branchCard('true')}>
173
+ <div style={styles.branchLabel('true')}>
174
+ <TrueIcon /> True Branch
175
+ </div>
176
+ <div style={styles.branchCount}>{trueStepsCount}</div>
177
+ <div style={styles.branchCountLabel}>
178
+ step{trueStepsCount !== 1 ? 's' : ''}
179
+ </div>
180
+ </div>
181
+ <div style={styles.branchCard('false')}>
182
+ <div style={styles.branchLabel('false')}>
183
+ <FalseIcon /> False Branch
184
+ </div>
185
+ <div style={styles.branchCount}>{falseStepsCount}</div>
186
+ <div style={styles.branchCountLabel}>
187
+ step{falseStepsCount !== 1 ? 's' : ''}
188
+ </div>
189
+ </div>
190
+ </div>
191
+
192
+ {isExpanded && (
193
+ <div style={{ marginTop: '12px' }}>
194
+ <div style={styles.field}>
195
+ <label style={styles.label}>Condition Examples</label>
196
+ <div style={{ fontSize: '11px', color: '#6b7280', lineHeight: 1.6 }}>
197
+ <div><code>status === 'active'</code> - Check equality</div>
198
+ <div><code>count {'>'} 10</code> - Numeric comparison</div>
199
+ <div><code>data && data.length {'>'} 0</code> - Check array</div>
200
+ <div><code>_record.metadata.type === 'premium'</code> - API input</div>
201
+ </div>
202
+ </div>
203
+ </div>
204
+ )}
205
+
206
+ <button
207
+ onClick={() => setIsExpanded(!isExpanded)}
208
+ style={{
209
+ width: '100%',
210
+ padding: '6px',
211
+ fontSize: '11px',
212
+ color: '#6366f1',
213
+ backgroundColor: 'transparent',
214
+ border: '1px dashed #e5e7eb',
215
+ borderRadius: '6px',
216
+ cursor: 'pointer',
217
+ marginTop: '12px',
218
+ }}
219
+ >
220
+ {isExpanded ? 'Hide Examples' : 'Show Examples'}
221
+ </button>
222
+ </BaseNode>
223
+ )
224
+ })
225
+
226
+ // ============================================================================
227
+ // Icon Components
228
+ // ============================================================================
229
+
230
+ const TrueIcon = () => (
231
+ <svg
232
+ width="12"
233
+ height="12"
234
+ viewBox="0 0 24 24"
235
+ fill="none"
236
+ stroke="currentColor"
237
+ strokeWidth="2"
238
+ strokeLinecap="round"
239
+ strokeLinejoin="round"
240
+ style={{ display: 'inline', marginRight: '4px', verticalAlign: 'middle' }}
241
+ >
242
+ <polyline points="20 6 9 17 4 12" />
243
+ </svg>
244
+ )
245
+
246
+ const FalseIcon = () => (
247
+ <svg
248
+ width="12"
249
+ height="12"
250
+ viewBox="0 0 24 24"
251
+ fill="none"
252
+ stroke="currentColor"
253
+ strokeWidth="2"
254
+ strokeLinecap="round"
255
+ strokeLinejoin="round"
256
+ style={{ display: 'inline', marginRight: '4px', verticalAlign: 'middle' }}
257
+ >
258
+ <line x1="18" y1="6" x2="6" y2="18" />
259
+ <line x1="6" y1="6" x2="18" y2="18" />
260
+ </svg>
261
+ )
262
+
263
+ export default ConditionalNode
264
+
@@ -0,0 +1,299 @@
1
+ import React, { memo, useCallback, useState } from 'react'
2
+ import type { NodeProps } from '@xyflow/react'
3
+ import { BaseNode, GlobeIcon, NODE_HEADER_COLORS } from './BaseNode'
4
+ import type { RuntypeNodeData, FetchUrlStepConfig, FlowStep } from '../../types'
5
+
6
+ // ============================================================================
7
+ // Styles
8
+ // ============================================================================
9
+
10
+ const styles = {
11
+ field: {
12
+ marginBottom: '12px',
13
+ },
14
+ label: {
15
+ display: 'block',
16
+ fontSize: '11px',
17
+ fontWeight: 600,
18
+ color: '#6b7280',
19
+ marginBottom: '4px',
20
+ textTransform: 'uppercase' as const,
21
+ letterSpacing: '0.03em',
22
+ },
23
+ input: {
24
+ width: '100%',
25
+ padding: '8px 10px',
26
+ fontSize: '12px',
27
+ border: '1px solid #e5e7eb',
28
+ borderRadius: '6px',
29
+ backgroundColor: '#f9fafb',
30
+ color: '#1f2937',
31
+ outline: 'none',
32
+ transition: 'border-color 0.15s ease, box-shadow 0.15s ease',
33
+ },
34
+ textarea: {
35
+ width: '100%',
36
+ padding: '8px 10px',
37
+ fontSize: '12px',
38
+ border: '1px solid #e5e7eb',
39
+ borderRadius: '6px',
40
+ backgroundColor: '#f9fafb',
41
+ color: '#1f2937',
42
+ outline: 'none',
43
+ resize: 'vertical' as const,
44
+ minHeight: '60px',
45
+ fontFamily: 'monospace',
46
+ transition: 'border-color 0.15s ease, box-shadow 0.15s ease',
47
+ },
48
+ select: {
49
+ width: '100%',
50
+ padding: '8px 10px',
51
+ fontSize: '12px',
52
+ border: '1px solid #e5e7eb',
53
+ borderRadius: '6px',
54
+ backgroundColor: '#f9fafb',
55
+ color: '#1f2937',
56
+ outline: 'none',
57
+ cursor: 'pointer',
58
+ },
59
+ row: {
60
+ display: 'flex',
61
+ gap: '8px',
62
+ },
63
+ methodBadge: (method: string) => {
64
+ const colors: Record<string, { bg: string; text: string }> = {
65
+ GET: { bg: '#d1fae5', text: '#059669' },
66
+ POST: { bg: '#dbeafe', text: '#1d4ed8' },
67
+ PUT: { bg: '#fef3c7', text: '#d97706' },
68
+ DELETE: { bg: '#fee2e2', text: '#dc2626' },
69
+ PATCH: { bg: '#e0e7ff', text: '#4f46e5' },
70
+ }
71
+ const color = colors[method] || colors.GET
72
+ return {
73
+ display: 'inline-flex',
74
+ alignItems: 'center',
75
+ padding: '2px 8px',
76
+ borderRadius: '4px',
77
+ fontSize: '10px',
78
+ fontWeight: 700,
79
+ backgroundColor: color.bg,
80
+ color: color.text,
81
+ fontFamily: 'monospace',
82
+ }
83
+ },
84
+ urlPreview: {
85
+ fontSize: '11px',
86
+ color: '#6b7280',
87
+ backgroundColor: '#f3f4f6',
88
+ padding: '6px 8px',
89
+ borderRadius: '4px',
90
+ fontFamily: 'monospace',
91
+ wordBreak: 'break-all' as const,
92
+ marginTop: '4px',
93
+ },
94
+ }
95
+
96
+ // ============================================================================
97
+ // Fetch URL Node Component
98
+ // ============================================================================
99
+
100
+ export const FetchUrlNode = memo(function FetchUrlNode(props: NodeProps) {
101
+ const { data, selected, id } = props as NodeProps & { data: RuntypeNodeData }
102
+ const { step, onChange } = data
103
+ const config = step.config as FetchUrlStepConfig
104
+
105
+ const [isExpanded, setIsExpanded] = useState(false)
106
+
107
+ const handleChange = useCallback(
108
+ (field: string, value: unknown) => {
109
+ if (field.startsWith('http.')) {
110
+ const httpField = field.replace('http.', '')
111
+ onChange?.(id, {
112
+ config: {
113
+ ...config,
114
+ http: {
115
+ ...config.http,
116
+ [httpField]: value,
117
+ },
118
+ },
119
+ } as Partial<FlowStep>)
120
+ } else {
121
+ onChange?.(id, {
122
+ config: {
123
+ ...config,
124
+ [field]: value,
125
+ },
126
+ } as Partial<FlowStep>)
127
+ }
128
+ },
129
+ [id, config, onChange]
130
+ )
131
+
132
+ const handleNameChange = useCallback(
133
+ (e: React.ChangeEvent<HTMLInputElement>) => {
134
+ // console.log('[FetchUrlNode] Name change:', e.target.value)
135
+ onChange?.(id, { name: e.target.value })
136
+ },
137
+ [id, onChange]
138
+ )
139
+
140
+ const method = config.http?.method || 'GET'
141
+
142
+ return (
143
+ <BaseNode
144
+ data={data}
145
+ selected={selected}
146
+ id={id}
147
+ typeLabel="Fetch URL"
148
+ icon={<GlobeIcon />}
149
+ headerColor={NODE_HEADER_COLORS['fetch-url']}
150
+ >
151
+ <div style={styles.field}>
152
+ <label style={styles.label}>Step Name</label>
153
+ <input
154
+ style={styles.input}
155
+ value={step.name}
156
+ onChange={handleNameChange}
157
+ placeholder="Enter step name"
158
+ onKeyDown={(e) => {
159
+ // console.log('[FetchUrlNode] KeyDown:', e.key)
160
+ e.stopPropagation()
161
+ }}
162
+ // onInput={(e) => console.log('[FetchUrlNode] Input event:', (e.target as HTMLInputElement).value)}
163
+ />
164
+ </div>
165
+
166
+ <div style={styles.field}>
167
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '4px' }}>
168
+ <label style={styles.label}>Method</label>
169
+ <span style={styles.methodBadge(method)}>{method}</span>
170
+ </div>
171
+ <select
172
+ style={styles.select}
173
+ value={method}
174
+ onChange={(e) => handleChange('http.method', e.target.value)}
175
+ onKeyDown={(e) => e.stopPropagation()}
176
+ >
177
+ <option value="GET">GET</option>
178
+ <option value="POST">POST</option>
179
+ <option value="PUT">PUT</option>
180
+ <option value="DELETE">DELETE</option>
181
+ <option value="PATCH">PATCH</option>
182
+ </select>
183
+ </div>
184
+
185
+ <div style={styles.field}>
186
+ <label style={styles.label}>URL</label>
187
+ <input
188
+ style={styles.input}
189
+ value={config.http?.url || ''}
190
+ onChange={(e) => handleChange('http.url', e.target.value)}
191
+ placeholder="https://api.example.com/endpoint"
192
+ onKeyDown={(e) => e.stopPropagation()}
193
+ />
194
+ {config.http?.url && (
195
+ <div style={styles.urlPreview}>
196
+ {config.http.url}
197
+ </div>
198
+ )}
199
+ </div>
200
+
201
+ {isExpanded && (
202
+ <>
203
+ {(method === 'POST' || method === 'PUT' || method === 'PATCH') && (
204
+ <div style={styles.field}>
205
+ <label style={styles.label}>Request Body</label>
206
+ <textarea
207
+ style={styles.textarea}
208
+ value={config.http?.body || ''}
209
+ onChange={(e) => handleChange('http.body', e.target.value)}
210
+ placeholder='{"key": "value"}'
211
+ rows={3}
212
+ onKeyDown={(e) => e.stopPropagation()}
213
+ />
214
+ </div>
215
+ )}
216
+
217
+ <div style={styles.field}>
218
+ <label style={styles.label}>Headers (JSON)</label>
219
+ <textarea
220
+ style={styles.textarea}
221
+ value={config.http?.headers ? JSON.stringify(config.http.headers, null, 2) : ''}
222
+ onChange={(e) => {
223
+ try {
224
+ const headers = JSON.parse(e.target.value)
225
+ handleChange('http.headers', headers)
226
+ } catch {
227
+ // Invalid JSON, don't update
228
+ }
229
+ }}
230
+ placeholder='{"Content-Type": "application/json"}'
231
+ rows={2}
232
+ onKeyDown={(e) => e.stopPropagation()}
233
+ />
234
+ </div>
235
+
236
+ <div style={styles.row}>
237
+ <div style={{ ...styles.field, flex: 1 }}>
238
+ <label style={styles.label}>Response Type</label>
239
+ <select
240
+ style={styles.select}
241
+ value={config.responseType || 'json'}
242
+ onChange={(e) => handleChange('responseType', e.target.value)}
243
+ onKeyDown={(e) => e.stopPropagation()}
244
+ >
245
+ <option value="json">JSON</option>
246
+ <option value="text">Text</option>
247
+ <option value="xml">XML</option>
248
+ </select>
249
+ </div>
250
+ <div style={{ ...styles.field, flex: 1 }}>
251
+ <label style={styles.label}>On Error</label>
252
+ <select
253
+ style={styles.select}
254
+ value={config.errorHandling || 'fail'}
255
+ onChange={(e) => handleChange('errorHandling', e.target.value)}
256
+ onKeyDown={(e) => e.stopPropagation()}
257
+ >
258
+ <option value="fail">Fail</option>
259
+ <option value="continue">Continue</option>
260
+ <option value="default">Use Default</option>
261
+ </select>
262
+ </div>
263
+ </div>
264
+
265
+ <div style={styles.field}>
266
+ <label style={styles.label}>Output Variable</label>
267
+ <input
268
+ style={styles.input}
269
+ value={config.outputVariable || ''}
270
+ onChange={(e) => handleChange('outputVariable', e.target.value)}
271
+ placeholder="api_response"
272
+ onKeyDown={(e) => e.stopPropagation()}
273
+ />
274
+ </div>
275
+ </>
276
+ )}
277
+
278
+ <button
279
+ onClick={() => setIsExpanded(!isExpanded)}
280
+ style={{
281
+ width: '100%',
282
+ padding: '6px',
283
+ fontSize: '11px',
284
+ color: '#6366f1',
285
+ backgroundColor: 'transparent',
286
+ border: '1px dashed #e5e7eb',
287
+ borderRadius: '6px',
288
+ cursor: 'pointer',
289
+ marginTop: '4px',
290
+ }}
291
+ >
292
+ {isExpanded ? 'Show Less' : 'Show More Options'}
293
+ </button>
294
+ </BaseNode>
295
+ )
296
+ })
297
+
298
+ export default FetchUrlNode
299
+