From ba2377f83b81ec03cd1bac4d997ba2168f2d8510 Mon Sep 17 00:00:00 2001 From: Waleed Date: Tue, 6 Jan 2026 16:01:32 -0800 Subject: [PATCH] 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 --- .../components/chat-message/chat-message.tsx | 4 +- .../components/combobox/combobox.tsx | 195 +++++++++++++++++- .../components/dropdown/dropdown.tsx | 91 +++++++- .../editor/components/sub-block/sub-block.tsx | 4 + apps/sim/blocks/types.ts | 10 +- 5 files changed, 285 insertions(+), 19 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/chat-message/chat-message.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/chat-message/chat-message.tsx index a66965021..2a01d630a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/chat-message/chat-message.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/chat-message/chat-message.tsx @@ -157,7 +157,7 @@ export function ChatMessage({ message }: ChatMessageProps) { {formattedContent && !formattedContent.startsWith('Uploaded') && (
-
+
@@ -168,7 +168,7 @@ export function ChatMessage({ message }: ChatMessageProps) { return (
-
+
{message.isStreaming && }
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx index 839a3334f..c5b8f67e2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useReactFlow } from 'reactflow' import { Combobox, type ComboboxOption } from '@/components/emcn/components' 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 { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes' 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 @@ -48,6 +51,19 @@ interface ComboBoxProps { placeholder?: string /** Configuration for the sub-block */ config: SubBlockConfig + /** Async function to fetch options dynamically */ + fetchOptions?: ( + blockId: string, + subBlockId: string + ) => Promise> + /** 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({ @@ -61,23 +77,89 @@ export function ComboBox({ disabled, placeholder = 'Type or select an option...', config, + fetchOptions, + fetchOptionById, + dependsOn, }: ComboBoxProps) { // Hooks and context const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId) const accessiblePrefixes = useAccessibleReferencePrefixes(blockId) 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 const [storeInitialized, setStoreInitialized] = useState(false) + const [fetchedOptions, setFetchedOptions] = useState>([]) + const [isLoadingOptions, setIsLoadingOptions] = useState(false) + const [fetchError, setFetchError] = useState(null) + const [hydratedOption, setHydratedOption] = useState<{ label: string; id: string } | null>(null) + const previousDependencyValuesRef = useRef('') + + /** + * 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) const value = isPreview ? previewValue : propValue !== undefined ? propValue : storeValue - // Evaluate options if provided as a function - const evaluatedOptions = useMemo(() => { + // Evaluate static options if provided as a function + const staticOptions = useMemo(() => { return typeof options === 'function' ? 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 const comboboxOptions = useMemo((): ComboboxOption[] => { return evaluatedOptions.map((option) => { @@ -160,6 +242,94 @@ export function ComboBox({ } }, [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 * Intercepts Ctrl/Cmd+Wheel to zoom the canvas @@ -247,11 +417,13 @@ export function ComboBox({ return option.id === newValue }) - if (!matchedOption) { - return - } - - const nextValue = typeof matchedOption === 'string' ? matchedOption : matchedOption.id + // If a matching option is found, store its ID; otherwise store the raw value + // (allows expressions like to be entered directly) + const nextValue = matchedOption + ? typeof matchedOption === 'string' + ? matchedOption + : matchedOption.id + : newValue setStoreValue(nextValue) }} isPreview={isPreview} @@ -293,6 +465,13 @@ export function ComboBox({ onWheel: handleWheel, autoComplete: 'off', }} + isLoading={isLoadingOptions} + error={fetchError} + onOpenChange={(open) => { + if (open) { + void fetchOptionsIfNeeded() + } + }} /> )} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx index 9a6d87d6c..8edd3f380 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx @@ -44,6 +44,12 @@ interface DropdownProps { blockId: string, subBlockId: string ) => Promise> + /** 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'] /** Enable search input in dropdown */ @@ -71,6 +77,7 @@ export function Dropdown({ placeholder = 'Select an option...', multiSelect = false, fetchOptions, + fetchOptionById, dependsOn, searchable = false, }: DropdownProps) { @@ -98,6 +105,7 @@ export function Dropdown({ const [fetchedOptions, setFetchedOptions] = useState>([]) const [isLoadingOptions, setIsLoadingOptions] = useState(false) const [fetchError, setFetchError] = useState(null) + const [hydratedOption, setHydratedOption] = useState<{ label: string; id: string } | null>(null) const previousModeRef = useRef(null) const previousDependencyValuesRef = useRef('') @@ -150,11 +158,23 @@ export function Dropdown({ }, [fetchedOptions]) const availableOptions = useMemo(() => { - if (fetchOptions && normalizedFetchedOptions.length > 0) { - return normalizedFetchedOptions + let opts: DropdownOption[] = + 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 @@ -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) */ useEffect(() => { @@ -323,6 +343,7 @@ export function Dropdown({ currentDependencyValuesStr !== previousDependencyValuesStr ) { setFetchedOptions([]) + setHydratedOption(null) } previousDependencyValuesRef.current = currentDependencyValuesStr @@ -338,18 +359,72 @@ export function Dropdown({ !isPreview && !disabled && fetchedOptions.length === 0 && - !isLoadingOptions + !isLoadingOptions && + !fetchError ) { fetchOptionsIfNeeded() } + // eslint-disable-next-line react-hooks/exhaustive-deps -- fetchOptionsIfNeeded deps already covered above }, [ fetchOptions, isPreview, disabled, fetchedOptions.length, isLoadingOptions, - fetchOptionsIfNeeded, - dependencyValues, // Refetch when dependencies change + fetchError, + 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, ]) /** diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx index 343d49f78..5b58335a5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx @@ -460,6 +460,7 @@ function SubBlockComponent({ disabled={isDisabled} multiSelect={config.multiSelect} fetchOptions={config.fetchOptions} + fetchOptionById={config.fetchOptionById} dependsOn={config.dependsOn} searchable={config.searchable} /> @@ -479,6 +480,9 @@ function SubBlockComponent({ previewValue={previewValue as any} disabled={isDisabled} config={config} + fetchOptions={config.fetchOptions} + fetchOptionById={config.fetchOptionById} + dependsOn={config.dependsOn} />
) diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts index c332e7528..2f54147f7 100644 --- a/apps/sim/blocks/types.ts +++ b/apps/sim/blocks/types.ts @@ -289,11 +289,19 @@ export interface SubBlockConfig { useWebhookUrl?: boolean // Trigger-save specific: The trigger ID for validation and saving 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?: ( blockId: string, subBlockId: string ) => Promise> + // 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 {