mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-07 22:24:06 -05:00
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:
@@ -157,7 +157,7 @@ export function ChatMessage({ message }: ChatMessageProps) {
|
||||
|
||||
{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='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} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -168,7 +168,7 @@ export function ChatMessage({ message }: ChatMessageProps) {
|
||||
|
||||
return (
|
||||
<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} />
|
||||
{message.isStreaming && <StreamingIndicator />}
|
||||
</div>
|
||||
|
||||
@@ -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<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({
|
||||
@@ -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<string>(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<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)
|
||||
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 <block.output> 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()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</SubBlockInputController>
|
||||
|
||||
@@ -44,6 +44,12 @@ interface DropdownProps {
|
||||
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']
|
||||
/** 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<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 previousModeRef = useRef<string | null>(null)
|
||||
const previousDependencyValuesRef = useRef<string>('')
|
||||
@@ -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,
|
||||
])
|
||||
|
||||
/**
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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<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> {
|
||||
|
||||
Reference in New Issue
Block a user