Skip to content

Commit bfb6fff

Browse files
authored
v0.5.52: new port-based router block, combobox expression and variable support
2 parents 4fbec0a + ba2377f commit bfb6fff

File tree

16 files changed

+1154
-133
lines changed

16 files changed

+1154
-133
lines changed

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/chat-message/chat-message.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ export function ChatMessage({ message }: ChatMessageProps) {
157157

158158
{formattedContent && !formattedContent.startsWith('Uploaded') && (
159159
<div className='rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[8px] py-[6px] transition-all duration-200'>
160-
<div className='whitespace-pre-wrap break-words font-medium font-sans text-gray-100 text-sm leading-[1.25rem]'>
160+
<div className='whitespace-pre-wrap break-words font-medium font-sans text-[var(--text-primary)] text-sm leading-[1.25rem]'>
161161
<WordWrap text={formattedContent} />
162162
</div>
163163
</div>
@@ -168,7 +168,7 @@ export function ChatMessage({ message }: ChatMessageProps) {
168168

169169
return (
170170
<div className='w-full max-w-full overflow-hidden pl-[2px] opacity-100 transition-opacity duration-200'>
171-
<div className='whitespace-pre-wrap break-words font-[470] font-season text-[#E8E8E8] text-sm leading-[1.25rem]'>
171+
<div className='whitespace-pre-wrap break-words font-[470] font-season text-[var(--text-primary)] text-sm leading-[1.25rem]'>
172172
<WordWrap text={formattedContent} />
173173
{message.isStreaming && <StreamingIndicator />}
174174
</div>

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

Lines changed: 187 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,94 @@ 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+
!fetchError
272+
) {
273+
fetchOptionsIfNeeded()
274+
}
275+
// eslint-disable-next-line react-hooks/exhaustive-deps -- fetchOptionsIfNeeded deps already covered above
276+
}, [
277+
fetchOptions,
278+
isPreview,
279+
disabled,
280+
fetchedOptions.length,
281+
isLoadingOptions,
282+
fetchError,
283+
dependencyValues,
284+
])
285+
286+
// Hydrate the stored value's label by fetching it individually
287+
useEffect(() => {
288+
if (!fetchOptionById || isPreview || disabled) return
289+
290+
const valueToHydrate = value as string | null | undefined
291+
if (!valueToHydrate) return
292+
293+
// Skip if value is an expression (not a real ID)
294+
if (valueToHydrate.startsWith('<') || valueToHydrate.includes('{{')) return
295+
296+
// Skip if already hydrated with the same value
297+
if (hydratedOption?.id === valueToHydrate) return
298+
299+
// Skip if value is already in fetched options or static options
300+
const alreadyInFetchedOptions = fetchedOptions.some((opt) => opt.id === valueToHydrate)
301+
const alreadyInStaticOptions = staticOptions.some((opt) =>
302+
typeof opt === 'string' ? opt === valueToHydrate : opt.id === valueToHydrate
303+
)
304+
if (alreadyInFetchedOptions || alreadyInStaticOptions) return
305+
306+
// Track if effect is still active (cleanup on unmount or value change)
307+
let isActive = true
308+
309+
// Fetch the hydrated option
310+
fetchOptionById(blockId, subBlockId, valueToHydrate)
311+
.then((option) => {
312+
if (isActive) setHydratedOption(option)
313+
})
314+
.catch(() => {
315+
if (isActive) setHydratedOption(null)
316+
})
317+
318+
return () => {
319+
isActive = false
320+
}
321+
}, [
322+
fetchOptionById,
323+
value,
324+
blockId,
325+
subBlockId,
326+
isPreview,
327+
disabled,
328+
fetchedOptions,
329+
staticOptions,
330+
hydratedOption?.id,
331+
])
332+
163333
/**
164334
* Handles wheel event for ReactFlow zoom control
165335
* Intercepts Ctrl/Cmd+Wheel to zoom the canvas
@@ -247,11 +417,13 @@ export function ComboBox({
247417
return option.id === newValue
248418
})
249419

250-
if (!matchedOption) {
251-
return
252-
}
253-
254-
const nextValue = typeof matchedOption === 'string' ? matchedOption : matchedOption.id
420+
// If a matching option is found, store its ID; otherwise store the raw value
421+
// (allows expressions like <block.output> to be entered directly)
422+
const nextValue = matchedOption
423+
? typeof matchedOption === 'string'
424+
? matchedOption
425+
: matchedOption.id
426+
: newValue
255427
setStoreValue(nextValue)
256428
}}
257429
isPreview={isPreview}
@@ -293,6 +465,13 @@ export function ComboBox({
293465
onWheel: handleWheel,
294466
autoComplete: 'off',
295467
}}
468+
isLoading={isLoadingOptions}
469+
error={fetchError}
470+
onOpenChange={(open) => {
471+
if (open) {
472+
void fetchOptionsIfNeeded()
473+
}
474+
}}
296475
/>
297476
)}
298477
</SubBlockInputController>

0 commit comments

Comments
 (0)