Skip to content

Commit ebe4a1d

Browse files
committed
feat(combobox): added expression support to combobox
1 parent d248557 commit ebe4a1d

File tree

4 files changed

+268
-14
lines changed

4 files changed

+268
-14
lines changed

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx

Lines changed: 181 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useEffect, useMemo, useState } from 'react'
1+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
22
import { useReactFlow } from 'reactflow'
33
import { Combobox, type ComboboxOption } from '@/components/emcn/components'
44
import { cn } from '@/lib/core/utils/cn'
@@ -7,6 +7,9 @@ import { SubBlockInputController } from '@/app/workspace/[workspaceId]/w/[workfl
77
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
88
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
99
import 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

5369
export 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>

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx

Lines changed: 74 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@ interface DropdownProps {
4444
blockId: string,
4545
subBlockId: string
4646
) => Promise<Array<{ label: string; id: string }>>
47+
/** Async function to fetch a single option's label by ID (for hydration) */
48+
fetchOptionById?: (
49+
blockId: string,
50+
subBlockId: string,
51+
optionId: string
52+
) => Promise<{ label: string; id: string } | null>
4753
/** Field dependencies that trigger option refetch when changed */
4854
dependsOn?: SubBlockConfig['dependsOn']
4955
/** Enable search input in dropdown */
@@ -71,6 +77,7 @@ export function Dropdown({
7177
placeholder = 'Select an option...',
7278
multiSelect = false,
7379
fetchOptions,
80+
fetchOptionById,
7481
dependsOn,
7582
searchable = false,
7683
}: DropdownProps) {
@@ -98,6 +105,7 @@ export function Dropdown({
98105
const [fetchedOptions, setFetchedOptions] = useState<Array<{ label: string; id: string }>>([])
99106
const [isLoadingOptions, setIsLoadingOptions] = useState(false)
100107
const [fetchError, setFetchError] = useState<string | null>(null)
108+
const [hydratedOption, setHydratedOption] = useState<{ label: string; id: string } | null>(null)
101109

102110
const previousModeRef = useRef<string | null>(null)
103111
const previousDependencyValuesRef = useRef<string>('')
@@ -150,11 +158,23 @@ export function Dropdown({
150158
}, [fetchedOptions])
151159

152160
const availableOptions = useMemo(() => {
153-
if (fetchOptions && normalizedFetchedOptions.length > 0) {
154-
return normalizedFetchedOptions
161+
let opts: DropdownOption[] =
162+
fetchOptions && normalizedFetchedOptions.length > 0
163+
? normalizedFetchedOptions
164+
: evaluatedOptions
165+
166+
// Merge hydrated option if not already present
167+
if (hydratedOption) {
168+
const alreadyPresent = opts.some((o) =>
169+
typeof o === 'string' ? o === hydratedOption.id : o.id === hydratedOption.id
170+
)
171+
if (!alreadyPresent) {
172+
opts = [hydratedOption, ...opts]
173+
}
155174
}
156-
return evaluatedOptions
157-
}, [fetchOptions, normalizedFetchedOptions, evaluatedOptions])
175+
176+
return opts
177+
}, [fetchOptions, normalizedFetchedOptions, evaluatedOptions, hydratedOption])
158178

159179
/**
160180
* Convert dropdown options to Combobox format
@@ -310,7 +330,7 @@ export function Dropdown({
310330
)
311331

312332
/**
313-
* Effect to clear fetched options when dependencies actually change
333+
* Effect to clear fetched options and hydrated option when dependencies actually change
314334
* This ensures options are refetched with new dependency values (e.g., new credentials)
315335
*/
316336
useEffect(() => {
@@ -323,6 +343,7 @@ export function Dropdown({
323343
currentDependencyValuesStr !== previousDependencyValuesStr
324344
) {
325345
setFetchedOptions([])
346+
setHydratedOption(null)
326347
}
327348

328349
previousDependencyValuesRef.current = currentDependencyValuesStr
@@ -352,6 +373,54 @@ export function Dropdown({
352373
dependencyValues, // Refetch when dependencies change
353374
])
354375

376+
/**
377+
* Effect to hydrate the stored value's label by fetching it individually
378+
* This ensures the correct label is shown before the full options list loads
379+
*/
380+
useEffect(() => {
381+
if (!fetchOptionById || isPreview || disabled) return
382+
383+
// Get the value to hydrate (single value only, not multi-select)
384+
const valueToHydrate = multiSelect ? null : (singleValue as string | null | undefined)
385+
if (!valueToHydrate) return
386+
387+
// Skip if value is an expression (not a real ID)
388+
if (valueToHydrate.startsWith('<') || valueToHydrate.includes('{{')) return
389+
390+
// Skip if already hydrated with the same value
391+
if (hydratedOption?.id === valueToHydrate) return
392+
393+
// Skip if value is already in fetched options
394+
const alreadyInOptions = fetchedOptions.some((opt) => opt.id === valueToHydrate)
395+
if (alreadyInOptions) return
396+
397+
// Track if effect is still active (cleanup on unmount or value change)
398+
let isActive = true
399+
400+
// Fetch the hydrated option
401+
fetchOptionById(blockId, subBlockId, valueToHydrate)
402+
.then((option) => {
403+
if (isActive) setHydratedOption(option)
404+
})
405+
.catch(() => {
406+
if (isActive) setHydratedOption(null)
407+
})
408+
409+
return () => {
410+
isActive = false
411+
}
412+
}, [
413+
fetchOptionById,
414+
singleValue,
415+
multiSelect,
416+
blockId,
417+
subBlockId,
418+
isPreview,
419+
disabled,
420+
fetchedOptions,
421+
hydratedOption?.id,
422+
])
423+
355424
/**
356425
* Custom overlay content for multi-select mode showing badges
357426
*/

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,7 @@ function SubBlockComponent({
460460
disabled={isDisabled}
461461
multiSelect={config.multiSelect}
462462
fetchOptions={config.fetchOptions}
463+
fetchOptionById={config.fetchOptionById}
463464
dependsOn={config.dependsOn}
464465
searchable={config.searchable}
465466
/>
@@ -479,6 +480,9 @@ function SubBlockComponent({
479480
previewValue={previewValue as any}
480481
disabled={isDisabled}
481482
config={config}
483+
fetchOptions={config.fetchOptions}
484+
fetchOptionById={config.fetchOptionById}
485+
dependsOn={config.dependsOn}
482486
/>
483487
</div>
484488
)

0 commit comments

Comments
 (0)