@@ -4,7 +4,6 @@ import { Handle, Position } from 'reactflow';
44import { Modal , Form } from 'react-bootstrap' ;
55import { getToolConfigSync } from '../utils/toolRegistry.js' ;
66import { DOCKER_IMAGES , DOCKER_TAGS , annotationByName } from '../utils/toolAnnotations.js' ;
7- import { useToast } from '../context/ToastContext.jsx' ;
87import TagDropdown from './TagDropdown.jsx' ;
98import { ScatterPropagationContext } from '../context/ScatterPropagationContext.jsx' ;
109import { WiredInputsContext } from '../context/WiredInputsContext.jsx' ;
@@ -46,10 +45,8 @@ const NodeComponent = ({ data, id }) => {
4645 const wiredContext = useContext ( WiredInputsContext ) ;
4746 const wiredInputs = wiredContext . get ( id ) || new Map ( ) ;
4847
49- const { showError, dismissMessage } = useToast ( ) ;
50- const JSON_ERROR_MSG = 'Invalid JSON entered. Please ensure entry is formatted appropriately.' ;
5148 const [ showModal , setShowModal ] = useState ( false ) ;
52- const [ textInput , setTextInput ] = useState ( data . parameters || '' ) ;
49+ const [ paramValues , setParamValues ] = useState ( { } ) ;
5350 const [ dockerVersion , setDockerVersion ] = useState ( data . dockerVersion || 'latest' ) ;
5451 const [ versionValid , setVersionValid ] = useState ( true ) ;
5552 const [ versionWarning , setVersionWarning ] = useState ( '' ) ;
@@ -60,19 +57,20 @@ const NodeComponent = ({ data, id }) => {
6057 const [ infoTooltipPos , setInfoTooltipPos ] = useState ( { top : 0 , left : 0 } ) ;
6158 const infoIconRef = useRef ( null ) ;
6259
63- // Get tool definition and optional inputs
60+ // Get tool definition
6461 const tool = getToolConfigSync ( data . label ) ;
65- const optionalInputs = tool ?. optionalInputs || { } ;
66- const hasDefinedTool = ! ! tool ;
6762 const dockerImage = tool ?. dockerImage || null ;
6863
69- // Required File/Directory inputs (shown as wired/unwired in modal)
70- const requiredFileInputs = useMemo ( ( ) => {
71- if ( ! tool ?. requiredInputs ) return { } ;
72- return Object . fromEntries (
73- Object . entries ( tool . requiredInputs )
74- . filter ( ( [ _ , def ] ) => def . type === 'File' || def . type === 'Directory' )
75- ) ;
64+ // All parameters split into required and optional
65+ const allParams = useMemo ( ( ) => {
66+ if ( ! tool ) return { required : [ ] , optional : [ ] } ;
67+ const required = Object . entries ( tool . requiredInputs || { } )
68+ . filter ( ( [ _ , def ] ) => def . type !== 'record' )
69+ . map ( ( [ name , def ] ) => ( { name, ...def } ) ) ;
70+ const optional = Object . entries ( tool . optionalInputs || { } )
71+ . filter ( ( [ _ , def ] ) => def . type !== 'record' )
72+ . map ( ( [ name , def ] ) => ( { name, ...def } ) ) ;
73+ return { required, optional } ;
7674 } , [ tool ] ) ;
7775
7876 // Get known tags for this tool's docker image
@@ -106,68 +104,105 @@ const NodeComponent = ({ data, id }) => {
106104 return annotationByName . get ( data . label ) || null ;
107105 } , [ data . label ] ) ;
108106
109- // Generate a helpful default JSON showing available optional parameters
110- const defaultJson = useMemo ( ( ) => {
111- if ( ! hasDefinedTool || Object . keys ( optionalInputs ) . length === 0 ) {
112- return '{\n \n}' ;
113- }
107+ // Update a single parameter value
108+ const updateParam = ( name , value ) => {
109+ setParamValues ( prev => ( { ...prev , [ name ] : value } ) ) ;
110+ } ;
111+
112+ // Clamp numeric value to bounds on blur
113+ const clampToBounds = ( name , param ) => {
114+ const val = paramValues [ name ] ;
115+ if ( val === null || val === undefined || ! param . bounds ) return ;
116+ const [ min , max ] = param . bounds ;
117+ if ( val < min ) updateParam ( name , min ) ;
118+ else if ( val > max ) updateParam ( name , max ) ;
119+ } ;
114120
115- const exampleParams = { } ;
116- Object . entries ( optionalInputs ) . forEach ( ( [ name , def ] ) => {
117- // Skip record types in example
118- if ( def . type === 'record' ) return ;
119-
120- // Generate example value based on type
121- switch ( def . type ) {
122- case 'boolean' :
123- exampleParams [ name ] = false ;
124- break ;
125- case 'int' :
126- exampleParams [ name ] = def . bounds ? def . bounds [ 0 ] : 0 ;
127- break ;
128- case 'double' :
129- exampleParams [ name ] = def . bounds ? def . bounds [ 0 ] : 0.0 ;
130- break ;
131- case 'string' :
132- exampleParams [ name ] = '' ;
133- break ;
134- default :
135- exampleParams [ name ] = null ;
136- }
137- } ) ;
138-
139- return JSON . stringify ( exampleParams , null , 4 ) ;
140- } , [ hasDefinedTool , optionalInputs ] ) ;
141-
142- // Generate help text showing available options
143- const optionsHelpText = useMemo ( ( ) => {
144- if ( ! hasDefinedTool || Object . keys ( optionalInputs ) . length === 0 ) {
145- return 'No optional parameters defined for this tool.' ;
121+ // Shared renderer for param inline controls (used by both required and optional sections)
122+ const renderParamControl = ( param , wiredInfo , isRequired ) => {
123+ const isFileType = param . type === 'File' || param . type === 'Directory' ;
124+
125+ if ( isFileType ) {
126+ // File/Directory: show wired source or runtime placeholder
127+ const content = wiredInfo ? (
128+ < span className = "input-source" >
129+ from { wiredInfo . sourceNodeLabel } / { wiredInfo . sourceOutput }
130+ </ span >
131+ ) : (
132+ < span className = "input-runtime" > runtime input</ span >
133+ ) ;
134+ // Required file types render inline (no wrapper div); optional get param-control wrapper
135+ return isRequired ? content : < div className = "param-control" > { content } </ div > ;
146136 }
147137
148- return Object . entries ( optionalInputs )
149- . filter ( ( [ _ , def ] ) => def . type !== 'record' )
150- . map ( ( [ name , def ] ) => `• ${ name } (${ def . type } ): ${ def . label } ` )
151- . join ( '\n' ) ;
152- } , [ hasDefinedTool , optionalInputs ] ) ;
138+ // Scalar types: render editable control
139+ const control = param . type === 'boolean' ? (
140+ < Form . Check
141+ type = "switch"
142+ id = { `param-${ id } -${ param . name } ` }
143+ checked = { paramValues [ param . name ] === true }
144+ onChange = { ( e ) => updateParam ( param . name , e . target . checked ) }
145+ className = "param-switch"
146+ />
147+ ) : param . options ? (
148+ < Form . Select
149+ size = "sm"
150+ className = "param-select"
151+ value = { paramValues [ param . name ] ?? '' }
152+ onChange = { ( e ) => updateParam ( param . name , e . target . value || null ) }
153+ >
154+ < option value = "" > -- default --</ option >
155+ { param . options . map ( ( opt ) => (
156+ < option key = { opt } value = { opt } > { opt } </ option >
157+ ) ) }
158+ </ Form . Select >
159+ ) : ( param . type === 'int' || param . type === 'double' || param . type === 'float' || param . type === 'long' ) ? (
160+ < Form . Control
161+ type = "number"
162+ size = "sm"
163+ className = "param-number"
164+ step = { param . type === 'int' || param . type === 'long' ? 1 : 0.01 }
165+ min = { param . bounds ? param . bounds [ 0 ] : undefined }
166+ max = { param . bounds ? param . bounds [ 1 ] : undefined }
167+ placeholder = { param . bounds ? `${ param . bounds [ 0 ] } ..${ param . bounds [ 1 ] } ` : '' }
168+ value = { paramValues [ param . name ] ?? '' }
169+ onChange = { ( e ) => {
170+ const val = e . target . value ;
171+ if ( val === '' ) {
172+ updateParam ( param . name , null ) ;
173+ } else {
174+ updateParam ( param . name , param . type === 'int' || param . type === 'long' ? parseInt ( val , 10 ) : parseFloat ( val ) ) ;
175+ }
176+ } }
177+ onBlur = { ( ) => clampToBounds ( param . name , param ) }
178+ />
179+ ) : (
180+ < Form . Control
181+ type = "text"
182+ size = "sm"
183+ className = "param-text"
184+ value = { paramValues [ param . name ] ?? '' }
185+ onChange = { ( e ) => updateParam ( param . name , e . target . value || null ) }
186+ />
187+ ) ;
188+
189+ return < div className = "param-control" > { control } </ div > ;
190+ } ;
153191
154192 const handleOpenModal = ( ) => {
155193 // Auto-enable scatter toggle if inherited from upstream (non-source node)
156194 if ( ! isSourceNode && isScatterInherited && ! scatterEnabled ) {
157195 setScatterEnabled ( true ) ;
158196 }
159197
160- let inputValue = textInput ;
161-
162- // Ensure inputValue is always a string before calling trim()
163- if ( typeof inputValue !== 'string' ) {
164- inputValue = JSON . stringify ( inputValue , null , 4 ) ;
165- }
166-
167- if ( ! inputValue . trim ( ) ) {
168- setTextInput ( defaultJson ) ;
198+ // Initialize paramValues from saved data (object or legacy JSON string)
199+ const existing = data . parameters ;
200+ if ( existing && typeof existing === 'object' && ! Array . isArray ( existing ) ) {
201+ setParamValues ( { ...existing } ) ;
202+ } else if ( typeof existing === 'string' && existing . trim ( ) ) {
203+ try { setParamValues ( JSON . parse ( existing ) ) ; } catch { setParamValues ( { } ) ; }
169204 } else {
170- setTextInput ( inputValue ) ;
205+ setParamValues ( { } ) ;
171206 }
172207
173208 setShowModal ( true ) ;
@@ -180,19 +215,9 @@ const NodeComponent = ({ data, id }) => {
180215 setDockerVersion ( finalDockerVersion ) ;
181216 }
182217
183- // Validate JSON before allowing close
184218 if ( typeof data . onSaveParameters === 'function' ) {
185- let parsed ;
186- try {
187- parsed = JSON . parse ( textInput ) ;
188- } catch ( err ) {
189- showError ( JSON_ERROR_MSG , 4000 ) ;
190- return ; // Keep modal open
191- }
192-
193- dismissMessage ( JSON_ERROR_MSG ) ;
194219 data . onSaveParameters ( {
195- params : parsed ,
220+ params : paramValues ,
196221 dockerVersion : finalDockerVersion ,
197222 scatterEnabled : scatterEnabled
198223 } ) ;
@@ -201,31 +226,6 @@ const NodeComponent = ({ data, id }) => {
201226 setShowModal ( false ) ;
202227 } ;
203228
204- const handleInputChange = ( e ) => {
205- setTextInput ( e . target . value ) ;
206- dismissMessage ( JSON_ERROR_MSG ) ;
207- } ;
208-
209- const handleKeyDown = ( e ) => {
210- if ( e . key === 'Tab' ) {
211- e . preventDefault ( ) ;
212- const tabSpaces = ' ' ; // Insert 4 spaces
213- const { selectionStart, selectionEnd } = e . target ;
214- const newValue =
215- textInput . substring ( 0 , selectionStart ) +
216- tabSpaces +
217- textInput . substring ( selectionEnd ) ;
218-
219- setTextInput ( newValue ) ;
220-
221- // Move cursor forward
222- setTimeout ( ( ) => {
223- e . target . selectionStart = e . target . selectionEnd =
224- selectionStart + tabSpaces . length ;
225- } , 0 ) ;
226- }
227- } ;
228-
229229 // Info icon hover handlers (simple tooltip, no click persistence)
230230 const handleInfoMouseEnter = ( ) => {
231231 if ( infoIconRef . current && toolInfo ) {
@@ -399,65 +399,70 @@ const NodeComponent = ({ data, id }) => {
399399 </ div >
400400 </ Form . Group >
401401
402- { /* Required Inputs (File/Directory) */ }
403- { Object . keys ( requiredFileInputs ) . length > 0 && (
404- < Form . Group className = "required-inputs-section" >
405- < Form . Label className = "modal-label" > Inputs</ Form . Label >
406- { Object . entries ( requiredFileInputs ) . map ( ( [ name , def ] ) => {
407- const wiredInfo = wiredInputs . get ( name ) ;
408- return (
409- < div key = { name } className = { `input-row ${ wiredInfo ? 'input-wired' : 'input-unwired' } ` } >
410- < span className = "input-name" > { def . label || name } </ span >
411- < span className = "input-type-badge" > { def . type } </ span >
412- { wiredInfo ? (
413- < span className = "input-source" >
414- from { wiredInfo . sourceNodeLabel } / { wiredInfo . sourceOutput }
415- </ span >
416- ) : (
417- < span className = "input-runtime" > supplied at runtime</ span >
418- ) }
419- </ div >
420- ) ;
421- } ) }
422- </ Form . Group >
423- ) }
424-
425- < Form . Group className = "mb-3" >
426- < Form . Label className = "modal-label" >
427- Configure optional parameters as JSON.
428- { ! hasDefinedTool && ' (Tool not fully defined - using generic parameters)' }
429- </ Form . Label >
430- < Form . Control
431- as = "textarea"
432- rows = { 8 }
433- value = { textInput }
434- onChange = { handleInputChange }
435- onKeyDown = { handleKeyDown }
436- className = "code-input"
437- spellCheck = "false"
438- autoCorrect = "off"
439- autoCapitalize = "off"
440- />
441- </ Form . Group >
442- { hasDefinedTool && Object . keys ( optionalInputs ) . length > 0 && (
443- < Form . Group >
444- < Form . Label className = "modal-label" style = { { fontSize : '0.8rem' , color : '#808080' } } >
445- Available options:
446- </ Form . Label >
447- < pre style = { {
448- fontSize : '0.75rem' ,
449- color : '#a0a0a0' ,
450- backgroundColor : '#1a1a1a' ,
451- padding : '8px' ,
452- borderRadius : '4px' ,
453- maxHeight : '150px' ,
454- overflow : 'auto' ,
455- whiteSpace : 'pre-wrap'
456- } } >
457- { optionsHelpText }
458- </ pre >
459- </ Form . Group >
460- ) }
402+ { /* Unified Parameter Pane */ }
403+ < div className = "params-scroll" >
404+ { /* Required Parameters */ }
405+ { allParams . required . length > 0 && (
406+ < div className = "param-section" >
407+ < div className = "param-section-header" > Required</ div >
408+ { allParams . required . map ( ( param ) => {
409+ const wiredInfo = wiredInputs . get ( param . name ) ;
410+ const isFileType = param . type === 'File' || param . type === 'Directory' ;
411+ return (
412+ < div key = { param . name } className = { `param-card ${ isFileType ? ( wiredInfo ? 'input-wired' : 'input-unwired' ) : '' } ` } >
413+ < div className = "param-card-header" >
414+ < span className = "param-name" > { param . name } </ span >
415+ < span className = "param-type-badge" > { param . type } </ span >
416+ { renderParamControl ( param , wiredInfo , true ) }
417+ </ div >
418+ { param . label && (
419+ < div className = "param-description" > { param . label } </ div >
420+ ) }
421+ { param . bounds && (
422+ < div className = "param-bounds" > bounds: { param . bounds [ 0 ] } – { param . bounds [ 1 ] } </ div >
423+ ) }
424+ </ div >
425+ ) ;
426+ } ) }
427+ </ div >
428+ ) }
429+
430+ { /* Optional Parameters */ }
431+ { allParams . optional . length > 0 && (
432+ < div className = "param-section" >
433+ < div className = "param-section-header" > Optional</ div >
434+ { allParams . optional . map ( ( param ) => {
435+ const wiredInfo = wiredInputs . get ( param . name ) ;
436+ const isFileType = param . type === 'File' || param . type === 'Directory' ;
437+ return (
438+ < div key = { param . name } className = { `param-card ${ isFileType && wiredInfo ? 'input-wired' : '' } ` } >
439+ < div className = "param-card-header" >
440+ < span className = "param-name" > { param . name } </ span >
441+ < span className = "param-type-badge" > { param . type } </ span >
442+ { renderParamControl ( param , wiredInfo , false ) }
443+ </ div >
444+ { param . label && (
445+ < div className = "param-description" > { param . label } </ div >
446+ ) }
447+ { param . bounds && (
448+ < div className = "param-bounds" > bounds: { param . bounds [ 0 ] } – { param . bounds [ 1 ] } </ div >
449+ ) }
450+ </ div >
451+ ) ;
452+ } ) }
453+ </ div >
454+ ) }
455+
456+ { /* Fallback for unknown tools */ }
457+ { ! tool && (
458+ < div className = "param-section" >
459+ < div className = "param-section-header" > Parameters</ div >
460+ < div className = "param-description" style = { { padding : '8px 0' } } >
461+ Tool not fully defined — parameters unavailable.
462+ </ div >
463+ </ div >
464+ ) }
465+ </ div >
461466 </ Form >
462467 </ Modal . Body >
463468 </ Modal >
0 commit comments