feat(combobox): added expression support to combobox (#2697)

* feat(combobox): added expression support to combobox

* fix chat messages styling in light mode

* last sec stuff

* ack comments
This commit is contained in:
Waleed
2026-01-06 16:01:32 -08:00
committed by GitHub
parent f502f984f3
commit ba2377f83b
5 changed files with 285 additions and 19 deletions

View File

@@ -157,7 +157,7 @@ export function ChatMessage({ message }: ChatMessageProps) {
{formattedContent && !formattedContent.startsWith('Uploaded') && ( {formattedContent && !formattedContent.startsWith('Uploaded') && (
<div className='rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[8px] py-[6px] transition-all duration-200'> <div className='rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[8px] py-[6px] transition-all duration-200'>
<div className='whitespace-pre-wrap break-words font-medium font-sans text-gray-100 text-sm leading-[1.25rem]'> <div className='whitespace-pre-wrap break-words font-medium font-sans text-[var(--text-primary)] text-sm leading-[1.25rem]'>
<WordWrap text={formattedContent} /> <WordWrap text={formattedContent} />
</div> </div>
</div> </div>
@@ -168,7 +168,7 @@ export function ChatMessage({ message }: ChatMessageProps) {
return ( return (
<div className='w-full max-w-full overflow-hidden pl-[2px] opacity-100 transition-opacity duration-200'> <div className='w-full max-w-full overflow-hidden pl-[2px] opacity-100 transition-opacity duration-200'>
<div className='whitespace-pre-wrap break-words font-[470] font-season text-[#E8E8E8] text-sm leading-[1.25rem]'> <div className='whitespace-pre-wrap break-words font-[470] font-season text-[var(--text-primary)] text-sm leading-[1.25rem]'>
<WordWrap text={formattedContent} /> <WordWrap text={formattedContent} />
{message.isStreaming && <StreamingIndicator />} {message.isStreaming && <StreamingIndicator />}
</div> </div>

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useReactFlow } from 'reactflow' import { useReactFlow } from 'reactflow'
import { Combobox, type ComboboxOption } from '@/components/emcn/components' import { Combobox, type ComboboxOption } from '@/components/emcn/components'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
@@ -7,6 +7,9 @@ import { SubBlockInputController } from '@/app/workspace/[workspaceId]/w/[workfl
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes' import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import type { SubBlockConfig } from '@/blocks/types' import type { SubBlockConfig } from '@/blocks/types'
import { getDependsOnFields } from '@/blocks/utils'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
/** /**
* Constants for ComboBox component behavior * Constants for ComboBox component behavior
@@ -48,6 +51,19 @@ interface ComboBoxProps {
placeholder?: string placeholder?: string
/** Configuration for the sub-block */ /** Configuration for the sub-block */
config: SubBlockConfig config: SubBlockConfig
/** Async function to fetch options dynamically */
fetchOptions?: (
blockId: string,
subBlockId: string
) => Promise<Array<{ label: string; id: string }>>
/** Async function to fetch a single option's label by ID (for hydration) */
fetchOptionById?: (
blockId: string,
subBlockId: string,
optionId: string
) => Promise<{ label: string; id: string } | null>
/** Field dependencies that trigger option refetch when changed */
dependsOn?: SubBlockConfig['dependsOn']
} }
export function ComboBox({ export function ComboBox({
@@ -61,23 +77,89 @@ export function ComboBox({
disabled, disabled,
placeholder = 'Type or select an option...', placeholder = 'Type or select an option...',
config, config,
fetchOptions,
fetchOptionById,
dependsOn,
}: ComboBoxProps) { }: ComboBoxProps) {
// Hooks and context // Hooks and context
const [storeValue, setStoreValue] = useSubBlockValue<string>(blockId, subBlockId) const [storeValue, setStoreValue] = useSubBlockValue<string>(blockId, subBlockId)
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId) const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
const reactFlowInstance = useReactFlow() const reactFlowInstance = useReactFlow()
// Dependency tracking for fetchOptions
const dependsOnFields = useMemo(() => getDependsOnFields(dependsOn), [dependsOn])
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
const dependencyValues = useSubBlockStore(
useCallback(
(state) => {
if (dependsOnFields.length === 0 || !activeWorkflowId) return []
const workflowValues = state.workflowValues[activeWorkflowId] || {}
const blockValues = workflowValues[blockId] || {}
return dependsOnFields.map((depKey) => blockValues[depKey] ?? null)
},
[dependsOnFields, activeWorkflowId, blockId]
)
)
// State management // State management
const [storeInitialized, setStoreInitialized] = useState(false) const [storeInitialized, setStoreInitialized] = useState(false)
const [fetchedOptions, setFetchedOptions] = useState<Array<{ label: string; id: string }>>([])
const [isLoadingOptions, setIsLoadingOptions] = useState(false)
const [fetchError, setFetchError] = useState<string | null>(null)
const [hydratedOption, setHydratedOption] = useState<{ label: string; id: string } | null>(null)
const previousDependencyValuesRef = useRef<string>('')
/**
* Fetches options from the async fetchOptions function if provided
*/
const fetchOptionsIfNeeded = useCallback(async () => {
if (!fetchOptions || isPreview || disabled) return
setIsLoadingOptions(true)
setFetchError(null)
try {
const options = await fetchOptions(blockId, subBlockId)
setFetchedOptions(options)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to fetch options'
setFetchError(errorMessage)
setFetchedOptions([])
} finally {
setIsLoadingOptions(false)
}
}, [fetchOptions, blockId, subBlockId, isPreview, disabled])
// Determine the active value based on mode (preview vs. controlled vs. store) // Determine the active value based on mode (preview vs. controlled vs. store)
const value = isPreview ? previewValue : propValue !== undefined ? propValue : storeValue const value = isPreview ? previewValue : propValue !== undefined ? propValue : storeValue
// Evaluate options if provided as a function // Evaluate static options if provided as a function
const evaluatedOptions = useMemo(() => { const staticOptions = useMemo(() => {
return typeof options === 'function' ? options() : options return typeof options === 'function' ? options() : options
}, [options]) }, [options])
// Normalize fetched options to match ComboBoxOption format
const normalizedFetchedOptions = useMemo((): ComboBoxOption[] => {
return fetchedOptions.map((opt) => ({ label: opt.label, id: opt.id }))
}, [fetchedOptions])
// Merge static and fetched options - fetched options take priority when available
const evaluatedOptions = useMemo((): ComboBoxOption[] => {
let opts: ComboBoxOption[] =
fetchOptions && normalizedFetchedOptions.length > 0 ? normalizedFetchedOptions : staticOptions
// Merge hydrated option if not already present
if (hydratedOption) {
const alreadyPresent = opts.some((o) =>
typeof o === 'string' ? o === hydratedOption.id : o.id === hydratedOption.id
)
if (!alreadyPresent) {
opts = [hydratedOption, ...opts]
}
}
return opts
}, [fetchOptions, normalizedFetchedOptions, staticOptions, hydratedOption])
// Convert options to Combobox format // Convert options to Combobox format
const comboboxOptions = useMemo((): ComboboxOption[] => { const comboboxOptions = useMemo((): ComboboxOption[] => {
return evaluatedOptions.map((option) => { return evaluatedOptions.map((option) => {
@@ -160,6 +242,94 @@ export function ComboBox({
} }
}, [storeInitialized, value, defaultOptionValue, setStoreValue]) }, [storeInitialized, value, defaultOptionValue, setStoreValue])
// Clear fetched options and hydrated option when dependencies change
useEffect(() => {
if (fetchOptions && dependsOnFields.length > 0) {
const currentDependencyValuesStr = JSON.stringify(dependencyValues)
const previousDependencyValuesStr = previousDependencyValuesRef.current
if (
previousDependencyValuesStr &&
currentDependencyValuesStr !== previousDependencyValuesStr
) {
setFetchedOptions([])
setHydratedOption(null)
}
previousDependencyValuesRef.current = currentDependencyValuesStr
}
}, [dependencyValues, fetchOptions, dependsOnFields.length])
// Fetch options when needed (on mount, when enabled, or when dependencies change)
useEffect(() => {
if (
fetchOptions &&
!isPreview &&
!disabled &&
fetchedOptions.length === 0 &&
!isLoadingOptions &&
!fetchError
) {
fetchOptionsIfNeeded()
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- fetchOptionsIfNeeded deps already covered above
}, [
fetchOptions,
isPreview,
disabled,
fetchedOptions.length,
isLoadingOptions,
fetchError,
dependencyValues,
])
// Hydrate the stored value's label by fetching it individually
useEffect(() => {
if (!fetchOptionById || isPreview || disabled) return
const valueToHydrate = value as string | null | undefined
if (!valueToHydrate) return
// Skip if value is an expression (not a real ID)
if (valueToHydrate.startsWith('<') || valueToHydrate.includes('{{')) return
// Skip if already hydrated with the same value
if (hydratedOption?.id === valueToHydrate) return
// Skip if value is already in fetched options or static options
const alreadyInFetchedOptions = fetchedOptions.some((opt) => opt.id === valueToHydrate)
const alreadyInStaticOptions = staticOptions.some((opt) =>
typeof opt === 'string' ? opt === valueToHydrate : opt.id === valueToHydrate
)
if (alreadyInFetchedOptions || alreadyInStaticOptions) return
// Track if effect is still active (cleanup on unmount or value change)
let isActive = true
// Fetch the hydrated option
fetchOptionById(blockId, subBlockId, valueToHydrate)
.then((option) => {
if (isActive) setHydratedOption(option)
})
.catch(() => {
if (isActive) setHydratedOption(null)
})
return () => {
isActive = false
}
}, [
fetchOptionById,
value,
blockId,
subBlockId,
isPreview,
disabled,
fetchedOptions,
staticOptions,
hydratedOption?.id,
])
/** /**
* Handles wheel event for ReactFlow zoom control * Handles wheel event for ReactFlow zoom control
* Intercepts Ctrl/Cmd+Wheel to zoom the canvas * Intercepts Ctrl/Cmd+Wheel to zoom the canvas
@@ -247,11 +417,13 @@ export function ComboBox({
return option.id === newValue return option.id === newValue
}) })
if (!matchedOption) { // If a matching option is found, store its ID; otherwise store the raw value
return // (allows expressions like <block.output> to be entered directly)
} const nextValue = matchedOption
? typeof matchedOption === 'string'
const nextValue = typeof matchedOption === 'string' ? matchedOption : matchedOption.id ? matchedOption
: matchedOption.id
: newValue
setStoreValue(nextValue) setStoreValue(nextValue)
}} }}
isPreview={isPreview} isPreview={isPreview}
@@ -293,6 +465,13 @@ export function ComboBox({
onWheel: handleWheel, onWheel: handleWheel,
autoComplete: 'off', autoComplete: 'off',
}} }}
isLoading={isLoadingOptions}
error={fetchError}
onOpenChange={(open) => {
if (open) {
void fetchOptionsIfNeeded()
}
}}
/> />
)} )}
</SubBlockInputController> </SubBlockInputController>

View File

@@ -44,6 +44,12 @@ interface DropdownProps {
blockId: string, blockId: string,
subBlockId: string subBlockId: string
) => Promise<Array<{ label: string; id: string }>> ) => Promise<Array<{ label: string; id: string }>>
/** Async function to fetch a single option's label by ID (for hydration) */
fetchOptionById?: (
blockId: string,
subBlockId: string,
optionId: string
) => Promise<{ label: string; id: string } | null>
/** Field dependencies that trigger option refetch when changed */ /** Field dependencies that trigger option refetch when changed */
dependsOn?: SubBlockConfig['dependsOn'] dependsOn?: SubBlockConfig['dependsOn']
/** Enable search input in dropdown */ /** Enable search input in dropdown */
@@ -71,6 +77,7 @@ export function Dropdown({
placeholder = 'Select an option...', placeholder = 'Select an option...',
multiSelect = false, multiSelect = false,
fetchOptions, fetchOptions,
fetchOptionById,
dependsOn, dependsOn,
searchable = false, searchable = false,
}: DropdownProps) { }: DropdownProps) {
@@ -98,6 +105,7 @@ export function Dropdown({
const [fetchedOptions, setFetchedOptions] = useState<Array<{ label: string; id: string }>>([]) const [fetchedOptions, setFetchedOptions] = useState<Array<{ label: string; id: string }>>([])
const [isLoadingOptions, setIsLoadingOptions] = useState(false) const [isLoadingOptions, setIsLoadingOptions] = useState(false)
const [fetchError, setFetchError] = useState<string | null>(null) const [fetchError, setFetchError] = useState<string | null>(null)
const [hydratedOption, setHydratedOption] = useState<{ label: string; id: string } | null>(null)
const previousModeRef = useRef<string | null>(null) const previousModeRef = useRef<string | null>(null)
const previousDependencyValuesRef = useRef<string>('') const previousDependencyValuesRef = useRef<string>('')
@@ -150,11 +158,23 @@ export function Dropdown({
}, [fetchedOptions]) }, [fetchedOptions])
const availableOptions = useMemo(() => { const availableOptions = useMemo(() => {
if (fetchOptions && normalizedFetchedOptions.length > 0) { let opts: DropdownOption[] =
return normalizedFetchedOptions fetchOptions && normalizedFetchedOptions.length > 0
? normalizedFetchedOptions
: evaluatedOptions
// Merge hydrated option if not already present
if (hydratedOption) {
const alreadyPresent = opts.some((o) =>
typeof o === 'string' ? o === hydratedOption.id : o.id === hydratedOption.id
)
if (!alreadyPresent) {
opts = [hydratedOption, ...opts]
}
} }
return evaluatedOptions
}, [fetchOptions, normalizedFetchedOptions, evaluatedOptions]) return opts
}, [fetchOptions, normalizedFetchedOptions, evaluatedOptions, hydratedOption])
/** /**
* Convert dropdown options to Combobox format * Convert dropdown options to Combobox format
@@ -310,7 +330,7 @@ export function Dropdown({
) )
/** /**
* Effect to clear fetched options when dependencies actually change * Effect to clear fetched options and hydrated option when dependencies actually change
* This ensures options are refetched with new dependency values (e.g., new credentials) * This ensures options are refetched with new dependency values (e.g., new credentials)
*/ */
useEffect(() => { useEffect(() => {
@@ -323,6 +343,7 @@ export function Dropdown({
currentDependencyValuesStr !== previousDependencyValuesStr currentDependencyValuesStr !== previousDependencyValuesStr
) { ) {
setFetchedOptions([]) setFetchedOptions([])
setHydratedOption(null)
} }
previousDependencyValuesRef.current = currentDependencyValuesStr previousDependencyValuesRef.current = currentDependencyValuesStr
@@ -338,18 +359,72 @@ export function Dropdown({
!isPreview && !isPreview &&
!disabled && !disabled &&
fetchedOptions.length === 0 && fetchedOptions.length === 0 &&
!isLoadingOptions !isLoadingOptions &&
!fetchError
) { ) {
fetchOptionsIfNeeded() fetchOptionsIfNeeded()
} }
// eslint-disable-next-line react-hooks/exhaustive-deps -- fetchOptionsIfNeeded deps already covered above
}, [ }, [
fetchOptions, fetchOptions,
isPreview, isPreview,
disabled, disabled,
fetchedOptions.length, fetchedOptions.length,
isLoadingOptions, isLoadingOptions,
fetchOptionsIfNeeded, fetchError,
dependencyValues, // Refetch when dependencies change dependencyValues,
])
/**
* Effect to hydrate the stored value's label by fetching it individually
* This ensures the correct label is shown before the full options list loads
*/
useEffect(() => {
if (!fetchOptionById || isPreview || disabled) return
// Get the value to hydrate (single value only, not multi-select)
const valueToHydrate = multiSelect ? null : (singleValue as string | null | undefined)
if (!valueToHydrate) return
// Skip if value is an expression (not a real ID)
if (valueToHydrate.startsWith('<') || valueToHydrate.includes('{{')) return
// Skip if already hydrated with the same value
if (hydratedOption?.id === valueToHydrate) return
// Skip if value is already in fetched options or static options
const alreadyInFetchedOptions = fetchedOptions.some((opt) => opt.id === valueToHydrate)
const alreadyInStaticOptions = evaluatedOptions.some((opt) =>
typeof opt === 'string' ? opt === valueToHydrate : opt.id === valueToHydrate
)
if (alreadyInFetchedOptions || alreadyInStaticOptions) return
// Track if effect is still active (cleanup on unmount or value change)
let isActive = true
// Fetch the hydrated option
fetchOptionById(blockId, subBlockId, valueToHydrate)
.then((option) => {
if (isActive) setHydratedOption(option)
})
.catch(() => {
if (isActive) setHydratedOption(null)
})
return () => {
isActive = false
}
}, [
fetchOptionById,
singleValue,
multiSelect,
blockId,
subBlockId,
isPreview,
disabled,
fetchedOptions,
evaluatedOptions,
hydratedOption?.id,
]) ])
/** /**

View File

@@ -460,6 +460,7 @@ function SubBlockComponent({
disabled={isDisabled} disabled={isDisabled}
multiSelect={config.multiSelect} multiSelect={config.multiSelect}
fetchOptions={config.fetchOptions} fetchOptions={config.fetchOptions}
fetchOptionById={config.fetchOptionById}
dependsOn={config.dependsOn} dependsOn={config.dependsOn}
searchable={config.searchable} searchable={config.searchable}
/> />
@@ -479,6 +480,9 @@ function SubBlockComponent({
previewValue={previewValue as any} previewValue={previewValue as any}
disabled={isDisabled} disabled={isDisabled}
config={config} config={config}
fetchOptions={config.fetchOptions}
fetchOptionById={config.fetchOptionById}
dependsOn={config.dependsOn}
/> />
</div> </div>
) )

View File

@@ -289,11 +289,19 @@ export interface SubBlockConfig {
useWebhookUrl?: boolean useWebhookUrl?: boolean
// Trigger-save specific: The trigger ID for validation and saving // Trigger-save specific: The trigger ID for validation and saving
triggerId?: string triggerId?: string
// Dropdown specific: Function to fetch options dynamically (for multi-select or single-select) // Dropdown/Combobox: Function to fetch options dynamically
// Works with both 'dropdown' (select-only) and 'combobox' (editable with expression support)
fetchOptions?: ( fetchOptions?: (
blockId: string, blockId: string,
subBlockId: string subBlockId: string
) => Promise<Array<{ label: string; id: string }>> ) => Promise<Array<{ label: string; id: string }>>
// Dropdown/Combobox: Function to fetch a single option's label by ID (for hydration)
// Called when component mounts with a stored value to display the correct label before options load
fetchOptionById?: (
blockId: string,
subBlockId: string,
optionId: string
) => Promise<{ label: string; id: string } | null>
} }
export interface BlockConfig<T extends ToolResponse = ToolResponse> { export interface BlockConfig<T extends ToolResponse = ToolResponse> {