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,88 @@ 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+ ) {
272+ fetchOptionsIfNeeded ( )
273+ }
274+ } , [
275+ fetchOptions ,
276+ isPreview ,
277+ disabled ,
278+ fetchedOptions . length ,
279+ isLoadingOptions ,
280+ fetchOptionsIfNeeded ,
281+ dependencyValues , // Refetch when dependencies change
282+ ] )
283+
284+ // Hydrate the stored value's label by fetching it individually
285+ useEffect ( ( ) => {
286+ if ( ! fetchOptionById || isPreview || disabled ) return
287+
288+ const valueToHydrate = value as string | null | undefined
289+ if ( ! valueToHydrate ) return
290+
291+ // Skip if value is an expression (not a real ID)
292+ if ( valueToHydrate . startsWith ( '<' ) || valueToHydrate . includes ( '{{' ) ) return
293+
294+ // Skip if already hydrated with the same value
295+ if ( hydratedOption ?. id === valueToHydrate ) return
296+
297+ // Skip if value is already in fetched options
298+ const alreadyInOptions = fetchedOptions . some ( ( opt ) => opt . id === valueToHydrate )
299+ if ( alreadyInOptions ) return
300+
301+ // Track if effect is still active (cleanup on unmount or value change)
302+ let isActive = true
303+
304+ // Fetch the hydrated option
305+ fetchOptionById ( blockId , subBlockId , valueToHydrate )
306+ . then ( ( option ) => {
307+ if ( isActive ) setHydratedOption ( option )
308+ } )
309+ . catch ( ( ) => {
310+ if ( isActive ) setHydratedOption ( null )
311+ } )
312+
313+ return ( ) => {
314+ isActive = false
315+ }
316+ } , [
317+ fetchOptionById ,
318+ value ,
319+ blockId ,
320+ subBlockId ,
321+ isPreview ,
322+ disabled ,
323+ fetchedOptions ,
324+ hydratedOption ?. id ,
325+ ] )
326+
163327 /**
164328 * Handles wheel event for ReactFlow zoom control
165329 * Intercepts Ctrl/Cmd+Wheel to zoom the canvas
@@ -247,11 +411,13 @@ export function ComboBox({
247411 return option . id === newValue
248412 } )
249413
250- if ( ! matchedOption ) {
251- return
252- }
253-
254- const nextValue = typeof matchedOption === 'string' ? matchedOption : matchedOption . id
414+ // If a matching option is found, store its ID; otherwise store the raw value
415+ // (allows expressions like <block.output> to be entered directly)
416+ const nextValue = matchedOption
417+ ? typeof matchedOption === 'string'
418+ ? matchedOption
419+ : matchedOption . id
420+ : newValue
255421 setStoreValue ( nextValue )
256422 } }
257423 isPreview = { isPreview }
@@ -293,6 +459,13 @@ export function ComboBox({
293459 onWheel : handleWheel ,
294460 autoComplete : 'off' ,
295461 } }
462+ isLoading = { isLoadingOptions }
463+ error = { fetchError }
464+ onOpenChange = { ( open ) => {
465+ if ( open ) {
466+ void fetchOptionsIfNeeded ( )
467+ }
468+ } }
296469 />
297470 ) }
298471 </ SubBlockInputController >
0 commit comments