11import { useEffect , useRef , useState } from 'react'
22import { useTranslation } from 'react-i18next'
3+ import { Trash2 } from 'lucide-react'
34import { Input } from '@/shared/components/ui/input'
45import { cn } from '@/shared/lib/utils'
56import { isValidHttpUrl , setHttpBodyError } from '../lib/http-node-validity'
@@ -30,6 +31,8 @@ interface Props {
3031 onChange : ( params : HttpParams ) => void
3132}
3233
34+ type PayloadTab = 'body' | 'headers'
35+
3336// ponytail: URL split via one regex, body highlighted via Prism (already
3437// vendored). Validity for save-blocking flows through http-node-validity —
3538// no context wiring.
@@ -45,6 +48,7 @@ export function HttpParamsEditor({ nodeId, nodes, params, readOnly, onChange }:
4548 const bodyRef = useRef < HTMLTextAreaElement > ( null )
4649 const [ bodyText , setBodyText ] = useState ( ( ) => bodyToText ( p . body ) )
4750 const [ bodyError , setBodyError ] = useState < string | null > ( null )
51+ const [ tab , setTab ] = useState < PayloadTab > ( 'body' )
4852
4953 useEffect ( ( ) => {
5054 setBodyText ( bodyToText ( p . body ) )
@@ -59,6 +63,10 @@ export function HttpParamsEditor({ nodeId, nodes, params, readOnly, onChange }:
5963 }
6064 } , [ showBody , nodeId ] )
6165
66+ useEffect ( ( ) => {
67+ if ( showBody ) setTab ( 'body' )
68+ } , [ showBody ] )
69+
6270 useEffect ( ( ) => ( ) => setHttpBodyError ( nodeId , null ) , [ nodeId ] )
6371
6472 const commitUrl = ( nextScheme : string , nextRest : string ) => {
@@ -126,6 +134,23 @@ export function HttpParamsEditor({ nodeId, nodes, params, readOnly, onChange }:
126134 }
127135 }
128136
137+ const switchTab = ( next : PayloadTab ) => {
138+ if ( next === tab ) return
139+ if ( tab === 'body' ) commitBody ( bodyText )
140+ setTab ( next )
141+ }
142+
143+ const headers = ( showLabel : boolean ) => (
144+ < HeaderRows
145+ headers = { p . headers }
146+ readOnly = { readOnly }
147+ nodes = { nodes }
148+ currentNodeId = { nodeId }
149+ showLabel = { showLabel }
150+ onChange = { ( next ) => onChange ( { ...p , headers : next } ) }
151+ />
152+ )
153+
129154 return (
130155 < div className = "space-y-2" >
131156 < div >
@@ -183,32 +208,180 @@ export function HttpParamsEditor({ nodeId, nodes, params, readOnly, onChange }:
183208 ) ) }
184209 </ select >
185210 </ div >
186- { showBody && (
211+ { showBody ? (
187212 < div >
188- < label className = "mb-1 block text-[10px] font-medium uppercase tracking-wider text-muted-foreground" >
189- { t ( 'soar.editor.canvas.http.body' ) }
190- </ label >
191- { ! readOnly && (
192- < div className = "mb-1 flex flex-wrap items-center gap-1.5" >
193- < InsertFieldMenu nodes = { nodes } currentNodeId = { nodeId } onInsert = { insertIntoBody } />
213+ < div className = "flex gap-1 border-b border-border" >
214+ < TabButton
215+ active = { tab === 'body' }
216+ onClick = { ( ) => switchTab ( 'body' ) }
217+ label = { t ( 'soar.editor.canvas.http.body' ) }
218+ />
219+ < TabButton
220+ active = { tab === 'headers' }
221+ onClick = { ( ) => switchTab ( 'headers' ) }
222+ label = { t ( 'soar.editor.canvas.http.headers' ) }
223+ />
224+ </ div >
225+ { tab === 'body' ? (
226+ < div >
227+ { ! readOnly && (
228+ < div className = "mb-1 flex flex-wrap items-center gap-1.5" >
229+ < InsertFieldMenu nodes = { nodes } currentNodeId = { nodeId } onInsert = { insertIntoBody } />
230+ </ div >
231+ ) }
232+ < JsonCodeEditor
233+ value = { bodyText }
234+ readOnly = { readOnly }
235+ placeholder = '{"foo":"bar"}'
236+ invalid = { Boolean ( bodyError ) }
237+ onChange = { setBodyText }
238+ onBlur = { ( ) => commitBody ( bodyText ) }
239+ textareaRef = { bodyRef }
240+ />
241+ { bodyError && (
242+ < p className = "mt-1 text-[10px] text-red-500" >
243+ { t ( 'soar.editor.canvas.http.bodyInvalid' ) } : { bodyError }
244+ </ p >
245+ ) }
194246 </ div >
195- ) }
196- < JsonCodeEditor
197- value = { bodyText }
198- readOnly = { readOnly }
199- placeholder = '{"foo":"bar"}'
200- invalid = { Boolean ( bodyError ) }
201- onChange = { setBodyText }
202- onBlur = { ( ) => commitBody ( bodyText ) }
203- textareaRef = { bodyRef }
204- />
205- { bodyError && (
206- < p className = "mt-1 text-[10px] text-red-500" >
207- { t ( 'soar.editor.canvas.http.bodyInvalid' ) } : { bodyError }
208- </ p >
247+ ) : (
248+ headers ( false )
209249 ) }
210250 </ div >
251+ ) : (
252+ headers ( true )
253+ ) }
254+ </ div >
255+ )
256+ }
257+
258+ function TabButton ( { active, onClick, label } : { active : boolean ; onClick : ( ) => void ; label : string } ) {
259+ return (
260+ < button
261+ type = "button"
262+ onClick = { onClick }
263+ className = { cn (
264+ 'rounded-t border-b-2 px-2 py-1 text-[10px] font-medium uppercase tracking-wider transition-colors' ,
265+ active
266+ ? 'border-primary text-foreground'
267+ : 'border-transparent text-muted-foreground hover:text-foreground' ,
211268 ) }
269+ >
270+ { label }
271+ </ button >
272+ )
273+ }
274+
275+ function HeaderRows ( {
276+ headers,
277+ readOnly,
278+ nodes,
279+ currentNodeId,
280+ showLabel = true ,
281+ onChange,
282+ } : {
283+ headers ?: Record < string , string >
284+ readOnly ?: boolean
285+ nodes : Record < string , FlowNode >
286+ currentNodeId : string
287+ showLabel ?: boolean
288+ onChange : ( next : Record < string , string > | undefined ) => void
289+ } ) {
290+ const { t } = useTranslation ( )
291+ const entries = Object . entries ( headers ?? { } )
292+ const valueRefs = useRef < Array < HTMLInputElement | null > > ( [ ] )
293+
294+ const commit = ( next : Array < [ string , string ] > ) => {
295+ const out : Record < string , string > = { }
296+ for ( const [ k , v ] of next ) {
297+ if ( k . trim ( ) ) out [ k . trim ( ) ] = v
298+ }
299+ onChange ( Object . keys ( out ) . length > 0 ? out : undefined )
300+ }
301+
302+ const setAt = ( i : number , patch : { key ?: string ; value ?: string } ) => {
303+ const next = entries . map ( ( [ k , v ] , j ) =>
304+ j === i ? ( [ patch . key ?? k , patch . value ?? v ] as [ string , string ] ) : ( [ k , v ] as [ string , string ] ) ,
305+ )
306+ commit ( next )
307+ }
308+
309+ const insertIntoValue = ( i : number , token : string ) => {
310+ const el = valueRefs . current [ i ]
311+ const cur = entries [ i ] ?. [ 1 ] ?? ''
312+ const start = el ?. selectionStart ?? cur . length
313+ const end = el ?. selectionEnd ?? cur . length
314+ setAt ( i , { value : cur . slice ( 0 , start ) + token + cur . slice ( end ) } )
315+ requestAnimationFrame ( ( ) => {
316+ const el2 = valueRefs . current [ i ]
317+ if ( ! el2 ) return
318+ el2 . focus ( )
319+ const pos = start + token . length
320+ el2 . setSelectionRange ( pos , pos )
321+ } )
322+ }
323+
324+ return (
325+ < div >
326+ < div className = "mb-1 flex items-center justify-between" >
327+ { showLabel && (
328+ < label className = "block text-[10px] font-medium uppercase tracking-wider text-muted-foreground" >
329+ { t ( 'soar.editor.canvas.http.headers' ) }
330+ </ label >
331+ ) }
332+ { ! readOnly && (
333+ < button
334+ type = "button"
335+ onClick = { ( ) => commit ( [ ...entries , [ '' , '' ] ] ) }
336+ className = "rounded px-1.5 py-0.5 text-[10px] text-primary hover:bg-muted"
337+ >
338+ { t ( 'soar.editor.canvas.http.addHeader' ) }
339+ </ button >
340+ ) }
341+ </ div >
342+ { entries . length === 0 && readOnly && (
343+ < p className = "text-[10px] text-muted-foreground" > —</ p >
344+ ) }
345+ < div className = "space-y-1" >
346+ { entries . map ( ( [ k , v ] , i ) => (
347+ < div key = { i } className = "flex items-center gap-1" >
348+ < Input
349+ value = { k }
350+ readOnly = { readOnly }
351+ onChange = { ( e ) => setAt ( i , { key : e . target . value } ) }
352+ placeholder = "Authorization"
353+ className = "h-7 w-2/5 font-mono text-[11px]"
354+ />
355+ < Input
356+ ref = { ( el ) => {
357+ valueRefs . current [ i ] = el
358+ } }
359+ value = { v }
360+ readOnly = { readOnly }
361+ onChange = { ( e ) => setAt ( i , { value : e . target . value } ) }
362+ placeholder = "Bearer $(variables.apiToken)"
363+ className = "h-7 flex-1 font-mono text-[11px]"
364+ />
365+ { ! readOnly && (
366+ < >
367+ < InsertFieldMenu
368+ nodes = { nodes }
369+ currentNodeId = { currentNodeId }
370+ onInsert = { ( token ) => insertIntoValue ( i , token ) }
371+ />
372+ < button
373+ type = "button"
374+ onClick = { ( ) => commit ( entries . filter ( ( _ , j ) => j !== i ) ) }
375+ className = "rounded p-1 text-muted-foreground hover:text-red-500"
376+ title = { t ( 'soar.editor.canvas.deleteNode' ) }
377+ >
378+ < Trash2 size = { 12 } />
379+ </ button >
380+ </ >
381+ ) }
382+ </ div >
383+ ) ) }
384+ </ div >
212385 </ div >
213386 )
214387}
0 commit comments