mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-22 13:28:04 -05:00
improved errors
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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, '<').replace(/>/g, '>')
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user