improved errors

This commit is contained in:
Lakee Sivaraya
2026-01-13 20:06:53 -08:00
parent 6605c887ed
commit c1eef30578
5 changed files with 579 additions and 122 deletions

View File

@@ -6,8 +6,10 @@ import { Button, Combobox, type ComboboxOption, Input } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import {
COMPARISON_OPERATORS,
conditionsToJsonString,
type FilterCondition,
generateFilterId,
jsonStringToConditions,
LOGICAL_OPERATORS,
} from '@/lib/table/filter-builder-utils'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
@@ -23,6 +25,10 @@ interface FilterFormatProps {
disabled?: boolean
columns?: Array<{ value: string; label: string }>
tableIdSubBlockId?: string
/** SubBlock ID for the mode dropdown (e.g., 'builderMode' or 'bulkFilterMode') */
modeSubBlockId?: string
/** SubBlock ID for the JSON filter (e.g., 'filter' or 'filterCriteria') */
jsonSubBlockId?: string
}
/**
@@ -44,14 +50,62 @@ export function FilterFormat({
disabled = false,
columns: propColumns,
tableIdSubBlockId = 'tableId',
modeSubBlockId,
jsonSubBlockId,
}: FilterFormatProps) {
const [storeValue, setStoreValue] = useSubBlockValue<FilterCondition[]>(blockId, subBlockId)
const [tableIdValue] = useSubBlockValue<string>(blockId, tableIdSubBlockId)
const [dynamicColumns, setDynamicColumns] = useState<ComboboxOption[]>([])
const fetchedTableIdRef = useRef<string | null>(null)
// For syncing with JSON editor mode
const [modeValue] = useSubBlockValue<string>(blockId, modeSubBlockId || '_unused_mode')
const [jsonValue, setJsonValue] = useSubBlockValue<string>(
blockId,
jsonSubBlockId || '_unused_json'
)
const prevModeRef = useRef<string | null>(null)
const isSyncingRef = useRef(false)
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
// Sync from JSON when switching to builder mode
useEffect(() => {
if (!modeSubBlockId || !jsonSubBlockId || isPreview) return
// Detect mode change to 'builder'
if (
prevModeRef.current !== null &&
prevModeRef.current !== 'builder' &&
modeValue === 'builder'
) {
// Switching from JSON to Builder - sync JSON to conditions
if (jsonValue && typeof jsonValue === 'string' && jsonValue.trim()) {
isSyncingRef.current = true
const conditions = jsonStringToConditions(jsonValue)
if (conditions.length > 0) {
setStoreValue(conditions)
}
isSyncingRef.current = false
}
}
prevModeRef.current = modeValue
}, [modeValue, jsonValue, modeSubBlockId, jsonSubBlockId, setStoreValue, isPreview])
// Sync to JSON when conditions change (and we're in builder mode)
useEffect(() => {
if (!modeSubBlockId || !jsonSubBlockId || isPreview || isSyncingRef.current) return
if (modeValue !== 'builder') return
const conditions = Array.isArray(storeValue) ? storeValue : []
if (conditions.length > 0) {
const jsonString = conditionsToJsonString(conditions)
if (jsonString !== jsonValue) {
setJsonValue(jsonString)
}
}
}, [storeValue, modeValue, modeSubBlockId, jsonSubBlockId, jsonValue, setJsonValue, isPreview])
// Fetch columns when tableId changes
useEffect(() => {
const fetchColumns = async () => {

View File

@@ -5,8 +5,10 @@ import { Plus, X } from 'lucide-react'
import { Button, Combobox, type ComboboxOption } from '@/components/emcn'
import {
generateSortId,
jsonStringToSortConditions,
SORT_DIRECTIONS,
type SortCondition,
sortConditionsToJsonString,
} from '@/lib/table/filter-builder-utils'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
@@ -18,6 +20,10 @@ interface SortFormatProps {
disabled?: boolean
columns?: Array<{ value: string; label: string }>
tableIdSubBlockId?: string
/** SubBlock ID for the mode dropdown (e.g., 'builderMode') */
modeSubBlockId?: string
/** SubBlock ID for the JSON sort (e.g., 'sort') */
jsonSubBlockId?: string
}
/**
@@ -37,12 +43,60 @@ export function SortFormat({
disabled = false,
columns: propColumns,
tableIdSubBlockId = 'tableId',
modeSubBlockId,
jsonSubBlockId,
}: SortFormatProps) {
const [storeValue, setStoreValue] = useSubBlockValue<SortCondition[]>(blockId, subBlockId)
const [tableIdValue] = useSubBlockValue<string>(blockId, tableIdSubBlockId)
const [dynamicColumns, setDynamicColumns] = useState<ComboboxOption[]>([])
const fetchedTableIdRef = useRef<string | null>(null)
// For syncing with JSON editor mode
const [modeValue] = useSubBlockValue<string>(blockId, modeSubBlockId || '_unused_mode')
const [jsonValue, setJsonValue] = useSubBlockValue<string>(
blockId,
jsonSubBlockId || '_unused_json'
)
const prevModeRef = useRef<string | null>(null)
const isSyncingRef = useRef(false)
// Sync from JSON when switching to builder mode
useEffect(() => {
if (!modeSubBlockId || !jsonSubBlockId || isPreview) return
// Detect mode change to 'builder'
if (
prevModeRef.current !== null &&
prevModeRef.current !== 'builder' &&
modeValue === 'builder'
) {
// Switching from JSON to Builder - sync JSON to conditions
if (jsonValue && typeof jsonValue === 'string' && jsonValue.trim()) {
isSyncingRef.current = true
const conditions = jsonStringToSortConditions(jsonValue)
if (conditions.length > 0) {
setStoreValue(conditions)
}
isSyncingRef.current = false
}
}
prevModeRef.current = modeValue
}, [modeValue, jsonValue, modeSubBlockId, jsonSubBlockId, setStoreValue, isPreview])
// Sync to JSON when conditions change (and we're in builder mode)
useEffect(() => {
if (!modeSubBlockId || !jsonSubBlockId || isPreview || isSyncingRef.current) return
if (modeValue !== 'builder') return
const conditions = Array.isArray(storeValue) ? storeValue : []
if (conditions.length > 0) {
const jsonString = sortConditionsToJsonString(conditions)
if (jsonString !== jsonValue) {
setJsonValue(jsonString)
}
}
}, [storeValue, modeValue, modeSubBlockId, jsonSubBlockId, jsonValue, setJsonValue, isPreview])
// Fetch columns when tableId changes
useEffect(() => {
const fetchColumns = async () => {

View File

@@ -1,5 +1,6 @@
import { useRef } from 'react'
import { useRef, useState } from 'react'
import { Plus } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Trash } from '@/components/emcn/icons/trash'
import 'prismjs/components/prism-json'
import Editor from 'react-simple-code-editor'
@@ -17,11 +18,26 @@ import {
} from '@/components/emcn'
import { Label } from '@/components/ui/label'
import { cn } from '@/lib/core/utils/cn'
import {
isLikelyReferenceSegment,
SYSTEM_REFERENCE_PREFIXES,
splitReferenceSegment,
} from '@/lib/workflows/sanitization/references'
import {
checkEnvVarTrigger,
EnvVarDropdown,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
import {
checkTagTrigger,
TagDropdown,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input'
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 { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation'
import { useTagSelection } from '@/hooks/kb/use-tag-selection'
import { normalizeName } from '@/stores/workflows/utils'
interface Field {
id: string
@@ -81,6 +97,61 @@ const createDefaultField = (): Field => ({
*/
const validateFieldName = (name: string): string => name.replace(/[\x00-\x1F"\\]/g, '').trim()
/**
* Placeholder type for code highlighting
*/
interface CodePlaceholder {
placeholder: string
original: string
type: 'var' | 'env'
}
/**
* Creates a syntax highlighter function with custom reference and environment variable highlighting.
*/
const createHighlightFunction = (
shouldHighlightReference: (part: string) => boolean
): ((codeToHighlight: string) => string) => {
return (codeToHighlight: string): string => {
const placeholders: CodePlaceholder[] = []
let processedCode = codeToHighlight
processedCode = processedCode.replace(createEnvVarPattern(), (match) => {
const placeholder = `__ENV_VAR_${placeholders.length}__`
placeholders.push({ placeholder, original: match, type: 'env' })
return placeholder
})
processedCode = processedCode.replace(createReferencePattern(), (match) => {
if (shouldHighlightReference(match)) {
const placeholder = `__VAR_REF_${placeholders.length}__`
placeholders.push({ placeholder, original: match, type: 'var' })
return placeholder
}
return match
})
let highlightedCode = highlight(processedCode, languages.json, 'json')
placeholders.forEach(({ placeholder, original, type }) => {
if (type === 'env') {
highlightedCode = highlightedCode.replace(
placeholder,
`<span style="color: var(--brand-secondary);">${original}</span>`
)
} else if (type === 'var') {
const escaped = original.replace(/</g, '&lt;').replace(/>/g, '&gt;')
highlightedCode = highlightedCode.replace(
placeholder,
`<span style="color: var(--brand-secondary);">${escaped}</span>`
)
}
})
return highlightedCode
}
}
export function FieldFormat({
blockId,
subBlockId,
@@ -93,12 +164,67 @@ export function FieldFormat({
showValue = false,
valuePlaceholder = 'Enter default value',
}: FieldFormatProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
const [storeValue, setStoreValue] = useSubBlockValue<Field[]>(blockId, subBlockId)
const valueInputRefs = useRef<Record<string, HTMLInputElement | HTMLTextAreaElement>>({})
const nameInputRefs = useRef<Record<string, HTMLInputElement>>({})
const overlayRefs = useRef<Record<string, HTMLDivElement>>({})
const nameOverlayRefs = useRef<Record<string, HTMLDivElement>>({})
const codeEditorRefs = useRef<Record<string, HTMLDivElement>>({})
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
const emitTagSelection = useTagSelection(blockId, subBlockId)
// State for code editor dropdowns (per field)
const [codeEditorDropdownState, setCodeEditorDropdownState] = useState<
Record<
string,
{
showTags: boolean
showEnvVars: boolean
searchTerm: string
cursorPosition: number
activeSourceBlockId: string | null
}
>
>({})
/**
* Determines whether a `<...>` segment should be highlighted as a reference.
*/
const shouldHighlightReference = (part: string): boolean => {
if (!part.startsWith('<') || !part.endsWith('>')) {
return false
}
if (!isLikelyReferenceSegment(part)) {
return false
}
const split = splitReferenceSegment(part)
if (!split) {
return false
}
const reference = split.reference
if (!accessiblePrefixes) {
return true
}
const inner = reference.slice(1, -1)
const [prefix] = inner.split('.')
const normalizedPrefix = normalizeName(prefix)
if (SYSTEM_REFERENCE_PREFIXES.has(normalizedPrefix)) {
return true
}
return accessiblePrefixes.has(normalizedPrefix)
}
const highlightCode = createHighlightFunction(shouldHighlightReference)
const inputController = useSubBlockInput({
blockId,
@@ -327,9 +453,25 @@ export function FieldFormat({
/>
)
if (field.type === 'object') {
// Code editor types with tag support
if (field.type === 'object' || field.type === 'array' || field.type === 'files') {
const lineCount = fieldValue.split('\n').length
const gutterWidth = calculateGutterWidth(lineCount)
const editorFieldKey = `code-${field.id}`
const dropdownState = codeEditorDropdownState[editorFieldKey] || {
showTags: false,
showEnvVars: false,
searchTerm: '',
cursorPosition: 0,
activeSourceBlockId: null,
}
const placeholders: Record<string, string> = {
object: '{\n "key": "value"\n}',
array: '[\n 1, 2, 3\n]',
files:
'[\n {\n "data": "<base64>",\n "type": "file",\n "name": "document.pdf",\n "mime": "application/pdf"\n }\n]',
}
const renderLineNumbers = () => {
return Array.from({ length: lineCount }, (_, i) => (
@@ -343,106 +485,189 @@ export function FieldFormat({
))
}
return (
<Code.Container className='min-h-[120px]'>
<Code.Gutter width={gutterWidth}>{renderLineNumbers()}</Code.Gutter>
<Code.Content paddingLeft={`${gutterWidth}px`}>
<Code.Placeholder gutterWidth={gutterWidth} show={fieldValue.length === 0}>
{'{\n "key": "value"\n}'}
</Code.Placeholder>
<Editor
value={fieldValue}
onValueChange={(newValue) => {
if (!isReadOnly) {
updateField(field.id, 'value', newValue)
}
}}
highlight={(code) => highlight(code, languages.json, 'json')}
disabled={isReadOnly}
{...getCodeEditorProps({ disabled: isReadOnly })}
/>
</Code.Content>
</Code.Container>
)
}
const handleCodeChange = (newValue: string) => {
if (isReadOnly) return
updateField(field.id, 'value', newValue)
if (field.type === 'array') {
const lineCount = fieldValue.split('\n').length
const gutterWidth = calculateGutterWidth(lineCount)
const editorContainer = codeEditorRefs.current[editorFieldKey]
const textarea = editorContainer?.querySelector('textarea')
if (textarea) {
const pos = textarea.selectionStart
const tagTrigger = checkTagTrigger(newValue, pos)
const envVarTrigger = checkEnvVarTrigger(newValue, pos)
const renderLineNumbers = () => {
return Array.from({ length: lineCount }, (_, i) => (
<div
key={i}
className='font-medium font-mono text-[var(--text-muted)] text-xs'
style={{ height: `${21}px`, lineHeight: `${21}px` }}
>
{i + 1}
</div>
))
setCodeEditorDropdownState((prev) => ({
...prev,
[editorFieldKey]: {
showTags: tagTrigger.show,
showEnvVars: envVarTrigger.show,
searchTerm: envVarTrigger.show ? envVarTrigger.searchTerm : '',
cursorPosition: pos,
activeSourceBlockId: tagTrigger.show ? dropdownState.activeSourceBlockId : null,
},
}))
}
}
const handleTagSelect = (newValue: string) => {
if (!isReadOnly) {
updateField(field.id, 'value', newValue)
emitTagSelection(newValue)
}
setCodeEditorDropdownState((prev) => ({
...prev,
[editorFieldKey]: {
...dropdownState,
showTags: false,
activeSourceBlockId: null,
},
}))
setTimeout(() => {
codeEditorRefs.current[editorFieldKey]?.querySelector('textarea')?.focus()
}, 0)
}
const handleEnvVarSelect = (newValue: string) => {
if (!isReadOnly) {
updateField(field.id, 'value', newValue)
emitTagSelection(newValue)
}
setCodeEditorDropdownState((prev) => ({
...prev,
[editorFieldKey]: {
...dropdownState,
showEnvVars: false,
searchTerm: '',
},
}))
setTimeout(() => {
codeEditorRefs.current[editorFieldKey]?.querySelector('textarea')?.focus()
}, 0)
}
const handleDrop = (e: React.DragEvent) => {
if (isReadOnly) return
e.preventDefault()
try {
const data = JSON.parse(e.dataTransfer.getData('application/json'))
if (data.type !== 'connectionBlock') return
const textarea = codeEditorRefs.current[editorFieldKey]?.querySelector('textarea')
const dropPosition = textarea?.selectionStart ?? fieldValue.length
const newValue = `${fieldValue.slice(0, dropPosition)}<${fieldValue.slice(dropPosition)}`
updateField(field.id, 'value', newValue)
const newCursorPosition = dropPosition + 1
setTimeout(() => {
if (textarea) {
textarea.focus()
textarea.selectionStart = newCursorPosition
textarea.selectionEnd = newCursorPosition
setCodeEditorDropdownState((prev) => ({
...prev,
[editorFieldKey]: {
showTags: true,
showEnvVars: false,
searchTerm: '',
cursorPosition: newCursorPosition,
activeSourceBlockId: data.connectionData?.sourceBlockId || null,
},
}))
}
}, 0)
} catch {
// Ignore drop errors
}
}
return (
<Code.Container className='min-h-[120px]'>
<Code.Gutter width={gutterWidth}>{renderLineNumbers()}</Code.Gutter>
<Code.Content paddingLeft={`${gutterWidth}px`}>
<Code.Placeholder gutterWidth={gutterWidth} show={fieldValue.length === 0}>
{'[\n 1, 2, 3\n]'}
</Code.Placeholder>
<Editor
value={fieldValue}
onValueChange={(newValue) => {
if (!isReadOnly) {
updateField(field.id, 'value', newValue)
}
}}
highlight={(code) => highlight(code, languages.json, 'json')}
disabled={isReadOnly}
{...getCodeEditorProps({ disabled: isReadOnly })}
/>
</Code.Content>
</Code.Container>
)
}
if (field.type === 'files') {
const lineCount = fieldValue.split('\n').length
const gutterWidth = calculateGutterWidth(lineCount)
const renderLineNumbers = () => {
return Array.from({ length: lineCount }, (_, i) => (
<div
key={i}
className='font-medium font-mono text-[var(--text-muted)] text-xs'
style={{ height: `${21}px`, lineHeight: `${21}px` }}
>
{i + 1}
</div>
))
}
return (
<Code.Container className='min-h-[120px]'>
<Code.Gutter width={gutterWidth}>{renderLineNumbers()}</Code.Gutter>
<Code.Content paddingLeft={`${gutterWidth}px`}>
<Code.Placeholder gutterWidth={gutterWidth} show={fieldValue.length === 0}>
{
'[\n {\n "data": "<base64>",\n "type": "file",\n "name": "document.pdf",\n "mime": "application/pdf"\n }\n]'
}
</Code.Placeholder>
<Editor
value={fieldValue}
onValueChange={(newValue) => {
if (!isReadOnly) {
updateField(field.id, 'value', newValue)
}
}}
highlight={(code) => highlight(code, languages.json, 'json')}
disabled={isReadOnly}
{...getCodeEditorProps({ disabled: isReadOnly })}
/>
</Code.Content>
</Code.Container>
<div
ref={(el) => {
if (el) codeEditorRefs.current[editorFieldKey] = el
}}
onDragOver={(e) => e.preventDefault()}
onDrop={handleDrop}
>
<Code.Container className='min-h-[120px]'>
<Code.Gutter width={gutterWidth}>{renderLineNumbers()}</Code.Gutter>
<Code.Content paddingLeft={`${gutterWidth}px`}>
<Code.Placeholder gutterWidth={gutterWidth} show={fieldValue.length === 0}>
{placeholders[field.type]}
</Code.Placeholder>
<Editor
value={fieldValue}
onValueChange={handleCodeChange}
onKeyDown={(e) => {
if (e.key === 'Escape') {
setCodeEditorDropdownState((prev) => ({
...prev,
[editorFieldKey]: {
...dropdownState,
showTags: false,
showEnvVars: false,
},
}))
}
}}
highlight={highlightCode}
disabled={isReadOnly}
{...getCodeEditorProps({ disabled: isReadOnly })}
/>
{dropdownState.showEnvVars && !isReadOnly && (
<EnvVarDropdown
visible={dropdownState.showEnvVars}
onSelect={handleEnvVarSelect}
searchTerm={dropdownState.searchTerm}
inputValue={fieldValue}
cursorPosition={dropdownState.cursorPosition}
workspaceId={workspaceId}
onClose={() => {
setCodeEditorDropdownState((prev) => ({
...prev,
[editorFieldKey]: {
...dropdownState,
showEnvVars: false,
searchTerm: '',
},
}))
}}
inputRef={{
current: codeEditorRefs.current[editorFieldKey]?.querySelector(
'textarea'
) as HTMLTextAreaElement,
}}
/>
)}
{dropdownState.showTags && !isReadOnly && (
<TagDropdown
visible={dropdownState.showTags}
onSelect={handleTagSelect}
blockId={blockId}
activeSourceBlockId={dropdownState.activeSourceBlockId}
inputValue={fieldValue}
cursorPosition={dropdownState.cursorPosition}
onClose={() => {
setCodeEditorDropdownState((prev) => ({
...prev,
[editorFieldKey]: {
...dropdownState,
showTags: false,
activeSourceBlockId: null,
},
}))
}}
inputRef={{
current: codeEditorRefs.current[editorFieldKey]?.querySelector(
'textarea'
) as HTMLTextAreaElement,
}}
/>
)}
</Code.Content>
</Code.Container>
</div>
)
}

View File

@@ -1,5 +1,6 @@
import { type JSX, type MouseEvent, memo, useRef, useState } from 'react'
import { AlertTriangle, Wand2 } from 'lucide-react'
import { type JSX, type MouseEvent, memo, useCallback, useRef, useState } from 'react'
import { AlertTriangle, ExternalLink, Wand2 } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Label, Tooltip } from '@/components/emcn/components'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/core/utils/cn'
@@ -46,6 +47,8 @@ import {
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import type { SubBlockConfig } from '@/blocks/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
/**
* Interface for wand control handlers exposed by sub-block inputs
@@ -293,6 +296,99 @@ const arePropsEqual = (prevProps: SubBlockProps, nextProps: SubBlockProps): bool
)
}
/**
* Props for the DropdownWithTableLink component
*/
interface DropdownWithTableLinkProps {
blockId: string
config: SubBlockConfig
isPreview: boolean
previewValue: string | string[] | null | undefined
isDisabled: boolean
handleMouseDown: (e: MouseEvent<HTMLDivElement>) => void
}
/**
* Renders a dropdown with an optional navigation link for table selectors.
* When the dropdown is for selecting a table (tableId), shows an icon button
* to navigate directly to the table page view.
*/
function DropdownWithTableLink({
blockId,
config,
isPreview,
previewValue,
isDisabled,
handleMouseDown,
}: DropdownWithTableLinkProps): JSX.Element {
const params = useParams()
const workspaceId = params.workspaceId as string
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
const tableId = useSubBlockStore(
useCallback(
(state) => {
if (!activeWorkflowId) return null
const value = state.workflowValues[activeWorkflowId]?.[blockId]?.[config.id]
return typeof value === 'string' ? value : null
},
[activeWorkflowId, blockId, config.id]
)
)
const isTableSelector = config.id === 'tableId'
const hasSelectedTable = isTableSelector && tableId && !tableId.startsWith('<')
const handleNavigateToTable = useCallback(
(e: MouseEvent) => {
e.stopPropagation()
if (tableId && workspaceId) {
window.open(`/workspace/${workspaceId}/tables/${tableId}`, '_blank')
}
},
[workspaceId, tableId]
)
return (
<div onMouseDown={handleMouseDown} className='flex items-center gap-[6px]'>
<div className='flex-1'>
<Dropdown
blockId={blockId}
subBlockId={config.id}
options={config.options as { label: string; id: string }[]}
defaultValue={typeof config.value === 'function' ? config.value({}) : config.value}
placeholder={config.placeholder}
isPreview={isPreview}
previewValue={previewValue}
disabled={isDisabled}
multiSelect={config.multiSelect}
fetchOptions={config.fetchOptions}
fetchOptionById={config.fetchOptionById}
dependsOn={config.dependsOn}
searchable={config.searchable}
/>
</div>
{hasSelectedTable && !isPreview && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
size='sm'
className='h-[30px] w-[30px] flex-shrink-0 p-0'
onClick={handleNavigateToTable}
>
<ExternalLink className='h-[14px] w-[14px] text-[var(--text-secondary)]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<p>View table</p>
</Tooltip.Content>
</Tooltip.Root>
)}
</div>
)
}
/**
* Renders a single workflow sub-block input based on config.type.
*
@@ -451,23 +547,14 @@ function SubBlockComponent({
case 'dropdown':
return (
<div onMouseDown={handleMouseDown}>
<Dropdown
blockId={blockId}
subBlockId={config.id}
options={config.options as { label: string; id: string }[]}
defaultValue={typeof config.value === 'function' ? config.value({}) : config.value}
placeholder={config.placeholder}
isPreview={isPreview}
previewValue={previewValue}
disabled={isDisabled}
multiSelect={config.multiSelect}
fetchOptions={config.fetchOptions}
fetchOptionById={config.fetchOptionById}
dependsOn={config.dependsOn}
searchable={config.searchable}
/>
</div>
<DropdownWithTableLink
blockId={blockId}
config={config}
isPreview={isPreview}
previewValue={previewValue}
isDisabled={isDisabled}
handleMouseDown={handleMouseDown}
/>
)
case 'combobox':
@@ -800,7 +887,17 @@ function SubBlockComponent({
/>
)
case 'filter-format':
case 'filter-format': {
// Determine sync props based on subBlockId
let modeSubBlockId: string | undefined
let jsonSubBlockId: string | undefined
if (config.id === 'filterBuilder') {
modeSubBlockId = 'builderMode'
jsonSubBlockId = 'filter'
} else if (config.id === 'bulkFilterBuilder') {
modeSubBlockId = 'bulkFilterMode'
jsonSubBlockId = 'filterCriteria'
}
return (
<FilterFormat
blockId={blockId}
@@ -808,10 +905,20 @@ function SubBlockComponent({
isPreview={isPreview}
previewValue={previewValue as FilterCondition[] | null | undefined}
disabled={isDisabled}
modeSubBlockId={modeSubBlockId}
jsonSubBlockId={jsonSubBlockId}
/>
)
}
case 'sort-format':
case 'sort-format': {
// Determine sync props based on subBlockId
let modeSubBlockId: string | undefined
let jsonSubBlockId: string | undefined
if (config.id === 'sortBuilder') {
modeSubBlockId = 'builderMode'
jsonSubBlockId = 'sort'
}
return (
<SortFormat
blockId={blockId}
@@ -819,8 +926,11 @@ function SubBlockComponent({
isPreview={isPreview}
previewValue={previewValue as SortCondition[] | null | undefined}
disabled={isDisabled}
modeSubBlockId={modeSubBlockId}
jsonSubBlockId={jsonSubBlockId}
/>
)
}
case 'channel-selector':
case 'user-selector':

View File

@@ -469,7 +469,8 @@ Return ONLY the sort JSON:`,
const { operation, ...rest } = params
/**
* Helper to parse JSON with better error messages
* Helper to parse JSON with better error messages.
* Also handles common issues with block references in JSON.
*/
const parseJSON = (value: string | any, fieldName: string): any => {
if (typeof value !== 'string') return value
@@ -478,9 +479,22 @@ Return ONLY the sort JSON:`,
return JSON.parse(value)
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error)
throw new Error(
`Invalid JSON in ${fieldName}: ${errorMsg}. Make sure all property names are in double quotes (e.g., {"name": "value"} not {name: "value"})`
// Check if the error might be due to unquoted string values (common when block references are resolved)
// This happens when users write {"field": <ref>} instead of {"field": "<ref>"}
const unquotedValueMatch = value.match(
/:\s*([a-zA-Z][a-zA-Z0-9_\s]*[a-zA-Z0-9]|[a-zA-Z])\s*[,}]/
)
let hint =
'Make sure all property names are in double quotes (e.g., {"name": "value"} not {name: "value"}).'
if (unquotedValueMatch) {
hint =
'It looks like a string value is not quoted. When using block references in JSON, wrap them in double quotes: {"field": "<blockName.output>"} not {"field": <blockName.output>}.'
}
throw new Error(`Invalid JSON in ${fieldName}: ${errorMsg}. ${hint}`)
}
}