1- import { useCallback , useEffect , useMemo , useState } from 'react'
1+ import { useCallback , useEffect , useMemo , useRef , useState } from 'react'
22import { useReactFlow } from 'reactflow'
33import { Combobox , type ComboboxOption } from '@/components/emcn/components'
44import { cn } from '@/lib/core/utils/cn'
@@ -7,6 +7,9 @@ import { SubBlockInputController } from '@/app/workspace/[workspaceId]/w/[workfl
77import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
88import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
99import type { SubBlockConfig } from '@/blocks/types'
10+ import { getDependsOnFields } from '@/blocks/utils'
11+ import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
12+ import { useSubBlockStore } from '@/stores/workflows/subblock/store'
1013
1114/**
1215 * Constants for ComboBox component behavior
@@ -48,6 +51,19 @@ interface ComboBoxProps {
4851 placeholder ?: string
4952 /** Configuration for the sub-block */
5053 config : SubBlockConfig
54+ /** Async function to fetch options dynamically */
55+ fetchOptions ?: (
56+ blockId : string ,
57+ subBlockId : string
58+ ) => Promise < Array < { label : string ; id : string } > >
59+ /** Async function to fetch a single option's label by ID (for hydration) */
60+ fetchOptionById ?: (
61+ blockId : string ,
62+ subBlockId : string ,
63+ optionId : string
64+ ) => Promise < { label : string ; id : string } | null >
65+ /** Field dependencies that trigger option refetch when changed */
66+ dependsOn ?: SubBlockConfig [ 'dependsOn' ]
5167}
5268
5369export function ComboBox ( {
@@ -61,23 +77,89 @@ export function ComboBox({
6177 disabled,
6278 placeholder = 'Type or select an option...' ,
6379 config,
80+ fetchOptions,
81+ fetchOptionById,
82+ dependsOn,
6483} : ComboBoxProps ) {
6584 // Hooks and context
6685 const [ storeValue , setStoreValue ] = useSubBlockValue < string > ( blockId , subBlockId )
6786 const accessiblePrefixes = useAccessibleReferencePrefixes ( blockId )
6887 const reactFlowInstance = useReactFlow ( )
6988
89+ // Dependency tracking for fetchOptions
90+ const dependsOnFields = useMemo ( ( ) => getDependsOnFields ( dependsOn ) , [ dependsOn ] )
91+ const activeWorkflowId = useWorkflowRegistry ( ( s ) => s . activeWorkflowId )
92+ const dependencyValues = useSubBlockStore (
93+ useCallback (
94+ ( state ) => {
95+ if ( dependsOnFields . length === 0 || ! activeWorkflowId ) return [ ]
96+ const workflowValues = state . workflowValues [ activeWorkflowId ] || { }
97+ const blockValues = workflowValues [ blockId ] || { }
98+ return dependsOnFields . map ( ( depKey ) => blockValues [ depKey ] ?? null )
99+ } ,
100+ [ dependsOnFields , activeWorkflowId , blockId ]
101+ )
102+ )
103+
70104 // State management
71105 const [ storeInitialized , setStoreInitialized ] = useState ( false )
106+ const [ fetchedOptions , setFetchedOptions ] = useState < Array < { label : string ; id : string } > > ( [ ] )
107+ const [ isLoadingOptions , setIsLoadingOptions ] = useState ( false )
108+ const [ fetchError , setFetchError ] = useState < string | null > ( null )
109+ const [ hydratedOption , setHydratedOption ] = useState < { label : string ; id : string } | null > ( null )
110+ const previousDependencyValuesRef = useRef < string > ( '' )
111+
112+ /**
113+ * Fetches options from the async fetchOptions function if provided
114+ */
115+ const fetchOptionsIfNeeded = useCallback ( async ( ) => {
116+ if ( ! fetchOptions || isPreview || disabled ) return
117+
118+ setIsLoadingOptions ( true )
119+ setFetchError ( null )
120+ try {
121+ const options = await fetchOptions ( blockId , subBlockId )
122+ setFetchedOptions ( options )
123+ } catch ( error ) {
124+ const errorMessage = error instanceof Error ? error . message : 'Failed to fetch options'
125+ setFetchError ( errorMessage )
126+ setFetchedOptions ( [ ] )
127+ } finally {
128+ setIsLoadingOptions ( false )
129+ }
130+ } , [ fetchOptions , blockId , subBlockId , isPreview , disabled ] )
72131
73132 // Determine the active value based on mode (preview vs. controlled vs. store)
74133 const value = isPreview ? previewValue : propValue !== undefined ? propValue : storeValue
75134
76- // Evaluate options if provided as a function
77- const evaluatedOptions = useMemo ( ( ) => {
135+ // Evaluate static options if provided as a function
136+ const staticOptions = useMemo ( ( ) => {
78137 return typeof options === 'function' ? options ( ) : options
79138 } , [ options ] )
80139
140+ // Normalize fetched options to match ComboBoxOption format
141+ const normalizedFetchedOptions = useMemo ( ( ) : ComboBoxOption [ ] => {
142+ return fetchedOptions . map ( ( opt ) => ( { label : opt . label , id : opt . id } ) )
143+ } , [ fetchedOptions ] )
144+
145+ // Merge static and fetched options - fetched options take priority when available
146+ const evaluatedOptions = useMemo ( ( ) : ComboBoxOption [ ] => {
147+ let opts : ComboBoxOption [ ] =
148+ fetchOptions && normalizedFetchedOptions . length > 0 ? normalizedFetchedOptions : staticOptions
149+
150+ // Merge hydrated option if not already present
151+ if ( hydratedOption ) {
152+ const alreadyPresent = opts . some ( ( o ) =>
153+ typeof o === 'string' ? o === hydratedOption . id : o . id === hydratedOption . id
154+ )
155+ if ( ! alreadyPresent ) {
156+ opts = [ hydratedOption , ...opts ]
157+ }
158+ }
159+
160+ return opts
161+ } , [ fetchOptions , normalizedFetchedOptions , staticOptions , hydratedOption ] )
162+
81163 // Convert options to Combobox format
82164 const comboboxOptions = useMemo ( ( ) : ComboboxOption [ ] => {
83165 return evaluatedOptions . map ( ( option ) => {
@@ -160,6 +242,94 @@ export function ComboBox({
160242 }
161243 } , [ storeInitialized , value , defaultOptionValue , setStoreValue ] )
162244
245+ // Clear fetched options and hydrated option when dependencies change
246+ useEffect ( ( ) => {
247+ if ( fetchOptions && dependsOnFields . length > 0 ) {
248+ const currentDependencyValuesStr = JSON . stringify ( dependencyValues )
249+ const previousDependencyValuesStr = previousDependencyValuesRef . current
250+
251+ if (
252+ previousDependencyValuesStr &&
253+ currentDependencyValuesStr !== previousDependencyValuesStr
254+ ) {
255+ setFetchedOptions ( [ ] )
256+ setHydratedOption ( null )
257+ }
258+
259+ previousDependencyValuesRef . current = currentDependencyValuesStr
260+ }
261+ } , [ dependencyValues , fetchOptions , dependsOnFields . length ] )
262+
263+ // Fetch options when needed (on mount, when enabled, or when dependencies change)
264+ useEffect ( ( ) => {
265+ if (
266+ fetchOptions &&
267+ ! isPreview &&
268+ ! disabled &&
269+ fetchedOptions . length === 0 &&
270+ ! isLoadingOptions &&
271+ ! fetchError
272+ ) {
273+ fetchOptionsIfNeeded ( )
274+ }
275+ // eslint-disable-next-line react-hooks/exhaustive-deps -- fetchOptionsIfNeeded deps already covered above
276+ } , [
277+ fetchOptions ,
278+ isPreview ,
279+ disabled ,
280+ fetchedOptions . length ,
281+ isLoadingOptions ,
282+ fetchError ,
283+ dependencyValues ,
284+ ] )
285+
286+ // Hydrate the stored value's label by fetching it individually
287+ useEffect ( ( ) => {
288+ if ( ! fetchOptionById || isPreview || disabled ) return
289+
290+ const valueToHydrate = value as string | null | undefined
291+ if ( ! valueToHydrate ) return
292+
293+ // Skip if value is an expression (not a real ID)
294+ if ( valueToHydrate . startsWith ( '<' ) || valueToHydrate . includes ( '{{' ) ) return
295+
296+ // Skip if already hydrated with the same value
297+ if ( hydratedOption ?. id === valueToHydrate ) return
298+
299+ // Skip if value is already in fetched options or static options
300+ const alreadyInFetchedOptions = fetchedOptions . some ( ( opt ) => opt . id === valueToHydrate )
301+ const alreadyInStaticOptions = staticOptions . some ( ( opt ) =>
302+ typeof opt === 'string' ? opt === valueToHydrate : opt . id === valueToHydrate
303+ )
304+ if ( alreadyInFetchedOptions || alreadyInStaticOptions ) return
305+
306+ // Track if effect is still active (cleanup on unmount or value change)
307+ let isActive = true
308+
309+ // Fetch the hydrated option
310+ fetchOptionById ( blockId , subBlockId , valueToHydrate )
311+ . then ( ( option ) => {
312+ if ( isActive ) setHydratedOption ( option )
313+ } )
314+ . catch ( ( ) => {
315+ if ( isActive ) setHydratedOption ( null )
316+ } )
317+
318+ return ( ) => {
319+ isActive = false
320+ }
321+ } , [
322+ fetchOptionById ,
323+ value ,
324+ blockId ,
325+ subBlockId ,
326+ isPreview ,
327+ disabled ,
328+ fetchedOptions ,
329+ staticOptions ,
330+ hydratedOption ?. id ,
331+ ] )
332+
163333 /**
164334 * Handles wheel event for ReactFlow zoom control
165335 * Intercepts Ctrl/Cmd+Wheel to zoom the canvas
@@ -247,11 +417,13 @@ export function ComboBox({
247417 return option . id === newValue
248418 } )
249419
250- if ( ! matchedOption ) {
251- return
252- }
253-
254- const nextValue = typeof matchedOption === 'string' ? matchedOption : matchedOption . id
420+ // If a matching option is found, store its ID; otherwise store the raw value
421+ // (allows expressions like <block.output> to be entered directly)
422+ const nextValue = matchedOption
423+ ? typeof matchedOption === 'string'
424+ ? matchedOption
425+ : matchedOption . id
426+ : newValue
255427 setStoreValue ( nextValue )
256428 } }
257429 isPreview = { isPreview }
@@ -293,6 +465,13 @@ export function ComboBox({
293465 onWheel : handleWheel ,
294466 autoComplete : 'off' ,
295467 } }
468+ isLoading = { isLoadingOptions }
469+ error = { fetchError }
470+ onOpenChange = { ( open ) => {
471+ if ( open ) {
472+ void fetchOptionsIfNeeded ( )
473+ }
474+ } }
296475 />
297476 ) }
298477 </ SubBlockInputController >
0 commit comments