mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
fix(variables, webhook): fix variable tag dropdown for escaped < and allow empty webhook payload (#1851)
* Fix << tags * Allow empty webhook body * Variable highlighting in loop conditions
This commit is contained in:
committed by
GitHub
parent
a73e2aaa8b
commit
f62568efc7
@@ -6,7 +6,6 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { OAuthService } from '@/lib/oauth/oauth'
|
||||
import { evaluateScopeCoverage, parseProvider } from '@/lib/oauth/oauth'
|
||||
import { getUserEntityPermissions } from '@/lib/permissions/utils'
|
||||
import { generateRequestId } from '@/lib/utils'
|
||||
|
||||
@@ -12,7 +12,14 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import 'prismjs/components/prism-javascript'
|
||||
import 'prismjs/themes/prism.css'
|
||||
|
||||
import {
|
||||
isLikelyReferenceSegment,
|
||||
SYSTEM_REFERENCE_PREFIXES,
|
||||
splitReferenceSegment,
|
||||
} from '@/lib/workflows/references'
|
||||
import type { LoopType, ParallelType } from '@/lib/workflows/types'
|
||||
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
|
||||
import { normalizeBlockName } from '@/stores/workflows/utils'
|
||||
|
||||
type IterationType = 'loop' | 'parallel'
|
||||
|
||||
@@ -130,6 +137,88 @@ export function IterationBadges({ nodeId, data, iterationType }: IterationBadges
|
||||
collaborativeUpdateIterationCount,
|
||||
collaborativeUpdateIterationCollection,
|
||||
} = useCollaborativeWorkflow()
|
||||
const accessiblePrefixes = useAccessibleReferencePrefixes(nodeId)
|
||||
|
||||
const shouldHighlightReference = useCallback(
|
||||
(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 = normalizeBlockName(prefix)
|
||||
|
||||
if (SYSTEM_REFERENCE_PREFIXES.has(normalizedPrefix)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return accessiblePrefixes.has(normalizedPrefix)
|
||||
},
|
||||
[accessiblePrefixes]
|
||||
)
|
||||
|
||||
const highlightWithReferences = useCallback(
|
||||
(code: string): string => {
|
||||
const placeholders: Array<{
|
||||
placeholder: string
|
||||
original: string
|
||||
type: 'var' | 'env'
|
||||
}> = []
|
||||
|
||||
let processedCode = code
|
||||
|
||||
processedCode = processedCode.replace(/\{\{([^}]+)\}\}/g, (match) => {
|
||||
const placeholder = `__ENV_VAR_${placeholders.length}__`
|
||||
placeholders.push({ placeholder, original: match, type: 'env' })
|
||||
return placeholder
|
||||
})
|
||||
|
||||
processedCode = processedCode.replace(/<[^>]+>/g, (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.javascript, 'javascript')
|
||||
|
||||
placeholders.forEach(({ placeholder, original, type }) => {
|
||||
if (type === 'env') {
|
||||
highlightedCode = highlightedCode.replace(
|
||||
placeholder,
|
||||
`<span class="text-blue-500">${original}</span>`
|
||||
)
|
||||
} else {
|
||||
const escaped = original.replace(/</g, '<').replace(/>/g, '>')
|
||||
highlightedCode = highlightedCode.replace(
|
||||
placeholder,
|
||||
`<span class="text-blue-500">${escaped}</span>`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return highlightedCode
|
||||
},
|
||||
[shouldHighlightReference]
|
||||
)
|
||||
|
||||
// Handle type change
|
||||
const handleTypeChange = useCallback(
|
||||
@@ -325,7 +414,7 @@ export function IterationBadges({ nodeId, data, iterationType }: IterationBadges
|
||||
<Editor
|
||||
value={conditionString}
|
||||
onValueChange={handleEditorChange}
|
||||
highlight={(code) => highlight(code, languages.javascript, 'javascript')}
|
||||
highlight={highlightWithReferences}
|
||||
padding={0}
|
||||
style={{
|
||||
fontFamily: 'monospace',
|
||||
@@ -363,7 +452,7 @@ export function IterationBadges({ nodeId, data, iterationType }: IterationBadges
|
||||
<Editor
|
||||
value={editorValue}
|
||||
onValueChange={handleEditorChange}
|
||||
highlight={(code) => highlight(code, languages.javascript, 'javascript')}
|
||||
highlight={highlightWithReferences}
|
||||
padding={0}
|
||||
style={{
|
||||
fontFamily: 'monospace',
|
||||
|
||||
@@ -13,7 +13,11 @@ import { checkTagTrigger, TagDropdown } from '@/components/ui/tag-dropdown'
|
||||
import { CodeLanguage } from '@/lib/execution/languages'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { isLikelyReferenceSegment, SYSTEM_REFERENCE_PREFIXES } from '@/lib/workflows/references'
|
||||
import {
|
||||
isLikelyReferenceSegment,
|
||||
SYSTEM_REFERENCE_PREFIXES,
|
||||
splitReferenceSegment,
|
||||
} from '@/lib/workflows/references'
|
||||
import { WandPromptBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-prompt-bar'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value'
|
||||
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
|
||||
@@ -433,11 +437,18 @@ IMPORTANT FORMATTING RULES:
|
||||
return false
|
||||
}
|
||||
|
||||
const split = splitReferenceSegment(part)
|
||||
if (!split) {
|
||||
return false
|
||||
}
|
||||
|
||||
const reference = split.reference
|
||||
|
||||
if (!accessiblePrefixes) {
|
||||
return true
|
||||
}
|
||||
|
||||
const inner = part.slice(1, -1)
|
||||
const inner = reference.slice(1, -1)
|
||||
const [prefix] = inner.split('.')
|
||||
const normalizedPrefix = normalizeBlockName(prefix)
|
||||
|
||||
|
||||
@@ -14,7 +14,11 @@ import { checkTagTrigger, TagDropdown } from '@/components/ui/tag-dropdown'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { isLikelyReferenceSegment, SYSTEM_REFERENCE_PREFIXES } from '@/lib/workflows/references'
|
||||
import {
|
||||
isLikelyReferenceSegment,
|
||||
SYSTEM_REFERENCE_PREFIXES,
|
||||
splitReferenceSegment,
|
||||
} from '@/lib/workflows/references'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value'
|
||||
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
|
||||
import { useTagSelection } from '@/hooks/use-tag-selection'
|
||||
@@ -74,11 +78,18 @@ export function ConditionInput({
|
||||
return false
|
||||
}
|
||||
|
||||
const split = splitReferenceSegment(part)
|
||||
if (!split) {
|
||||
return false
|
||||
}
|
||||
|
||||
const reference = split.reference
|
||||
|
||||
if (!accessiblePrefixes) {
|
||||
return true
|
||||
}
|
||||
|
||||
const inner = part.slice(1, -1)
|
||||
const inner = reference.slice(1, -1)
|
||||
const [prefix] = inner.split('.')
|
||||
const normalizedPrefix = normalizeBlockName(prefix)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { splitReferenceSegment } from '@/lib/workflows/references'
|
||||
import { normalizeBlockName } from '@/stores/workflows/utils'
|
||||
|
||||
export interface HighlightContext {
|
||||
@@ -17,8 +18,8 @@ const SYSTEM_PREFIXES = new Set(['start', 'loop', 'parallel', 'variable'])
|
||||
export function formatDisplayText(text: string, context?: HighlightContext): ReactNode[] {
|
||||
if (!text) return []
|
||||
|
||||
const shouldHighlightPart = (part: string): boolean => {
|
||||
if (!part.startsWith('<') || !part.endsWith('>')) {
|
||||
const shouldHighlightReference = (reference: string): boolean => {
|
||||
if (!reference.startsWith('<') || !reference.endsWith('>')) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -26,7 +27,7 @@ export function formatDisplayText(text: string, context?: HighlightContext): Rea
|
||||
return true
|
||||
}
|
||||
|
||||
const inner = part.slice(1, -1)
|
||||
const inner = reference.slice(1, -1)
|
||||
const [prefix] = inner.split('.')
|
||||
const normalizedPrefix = normalizeBlockName(prefix)
|
||||
|
||||
@@ -41,17 +42,52 @@ export function formatDisplayText(text: string, context?: HighlightContext): Rea
|
||||
return false
|
||||
}
|
||||
|
||||
const parts = text.split(/(<[^>]+>|\{\{[^}]+\}\})/g)
|
||||
const nodes: ReactNode[] = []
|
||||
const regex = /<[^>]+>|\{\{[^}]+\}\}/g
|
||||
let lastIndex = 0
|
||||
let key = 0
|
||||
|
||||
return parts.map((part, index) => {
|
||||
if (shouldHighlightPart(part) || part.match(/^\{\{[^}]+\}\}$/)) {
|
||||
return (
|
||||
<span key={index} className='text-blue-500'>
|
||||
{part}
|
||||
</span>
|
||||
)
|
||||
const pushPlainText = (value: string) => {
|
||||
if (!value) return
|
||||
nodes.push(<span key={key++}>{value}</span>)
|
||||
}
|
||||
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
const matchText = match[0]
|
||||
const index = match.index
|
||||
|
||||
if (index > lastIndex) {
|
||||
pushPlainText(text.slice(lastIndex, index))
|
||||
}
|
||||
|
||||
return <span key={index}>{part}</span>
|
||||
})
|
||||
if (matchText.startsWith('{{')) {
|
||||
nodes.push(
|
||||
<span key={key++} className='text-blue-500'>
|
||||
{matchText}
|
||||
</span>
|
||||
)
|
||||
} else {
|
||||
const split = splitReferenceSegment(matchText)
|
||||
|
||||
if (split && shouldHighlightReference(split.reference)) {
|
||||
pushPlainText(split.leading)
|
||||
nodes.push(
|
||||
<span key={key++} className='text-blue-500'>
|
||||
{split.reference}
|
||||
</span>
|
||||
)
|
||||
} else {
|
||||
nodes.push(<span key={key++}>{matchText}</span>)
|
||||
}
|
||||
}
|
||||
|
||||
lastIndex = regex.lastIndex
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
pushPlainText(text.slice(lastIndex))
|
||||
}
|
||||
|
||||
return nodes
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { checkTagTrigger } from '@/components/ui/tag-dropdown'
|
||||
import { checkTagTrigger, getTagSearchTerm } from '@/components/ui/tag-dropdown'
|
||||
import { extractFieldsFromSchema, parseResponseFormatSafely } from '@/lib/response-format'
|
||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||
import { generateLoopBlocks } from '@/stores/workflows/workflow/utils'
|
||||
@@ -1002,14 +1002,11 @@ describe('TagDropdown Search and Filtering', () => {
|
||||
{ input: 'Hello <loop.in', cursorPosition: 14, expected: 'loop.in' },
|
||||
{ input: 'Hello world', cursorPosition: 11, expected: '' },
|
||||
{ input: 'Hello <var> and <loo', cursorPosition: 20, expected: 'loo' },
|
||||
{ input: '<block.output> < <', cursorPosition: 18, expected: '' },
|
||||
]
|
||||
|
||||
testCases.forEach(({ input, cursorPosition, expected }) => {
|
||||
const textBeforeCursor = input.slice(0, cursorPosition)
|
||||
const match = textBeforeCursor.match(/<([^>]*)$/)
|
||||
const searchTerm = match ? match[1].toLowerCase() : ''
|
||||
|
||||
expect(searchTerm).toBe(expected)
|
||||
expect(getTagSearchTerm(input, cursorPosition)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -59,6 +59,27 @@ export const checkTagTrigger = (text: string, cursorPosition: number): { show: b
|
||||
return { show: false }
|
||||
}
|
||||
|
||||
export const getTagSearchTerm = (text: string, cursorPosition: number): string => {
|
||||
if (cursorPosition <= 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const textBeforeCursor = text.slice(0, cursorPosition)
|
||||
const lastOpenBracket = textBeforeCursor.lastIndexOf('<')
|
||||
|
||||
if (lastOpenBracket === -1) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const lastCloseBracket = textBeforeCursor.lastIndexOf('>')
|
||||
|
||||
if (lastCloseBracket > lastOpenBracket) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return textBeforeCursor.slice(lastOpenBracket + 1).toLowerCase()
|
||||
}
|
||||
|
||||
const BLOCK_COLORS = {
|
||||
VARIABLE: '#2F8BFF',
|
||||
DEFAULT: '#2F55FF',
|
||||
@@ -344,11 +365,10 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
const getVariablesByWorkflowId = useVariablesStore((state) => state.getVariablesByWorkflowId)
|
||||
const workflowVariables = workflowId ? getVariablesByWorkflowId(workflowId) : []
|
||||
|
||||
const searchTerm = useMemo(() => {
|
||||
const textBeforeCursor = inputValue.slice(0, cursorPosition)
|
||||
const match = textBeforeCursor.match(/<([^>]*)$/)
|
||||
return match ? match[1].toLowerCase() : ''
|
||||
}, [inputValue, cursorPosition])
|
||||
const searchTerm = useMemo(
|
||||
() => getTagSearchTerm(inputValue, cursorPosition),
|
||||
[inputValue, cursorPosition]
|
||||
)
|
||||
|
||||
const {
|
||||
tags,
|
||||
|
||||
@@ -65,9 +65,10 @@ export async function parseWebhookBody(
|
||||
const requestClone = request.clone()
|
||||
rawBody = await requestClone.text()
|
||||
|
||||
// Allow empty body - some webhooks send empty payloads
|
||||
if (!rawBody || rawBody.length === 0) {
|
||||
logger.warn(`[${requestId}] Rejecting request with empty body`)
|
||||
return new NextResponse('Empty request body', { status: 400 })
|
||||
logger.debug(`[${requestId}] Received request with empty body, treating as empty object`)
|
||||
return { body: {}, rawBody: '' }
|
||||
}
|
||||
} catch (bodyError) {
|
||||
logger.error(`[${requestId}] Failed to read request body`, {
|
||||
@@ -96,9 +97,9 @@ export async function parseWebhookBody(
|
||||
logger.debug(`[${requestId}] Parsed JSON webhook payload`)
|
||||
}
|
||||
|
||||
// Allow empty JSON objects - some webhooks send empty payloads
|
||||
if (Object.keys(body).length === 0) {
|
||||
logger.warn(`[${requestId}] Rejecting empty JSON object`)
|
||||
return new NextResponse('Empty JSON payload', { status: 400 })
|
||||
logger.debug(`[${requestId}] Received empty JSON object`)
|
||||
}
|
||||
} catch (parseError) {
|
||||
logger.error(`[${requestId}] Failed to parse webhook body`, {
|
||||
|
||||
43
apps/sim/lib/workflows/references.test.ts
Normal file
43
apps/sim/lib/workflows/references.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isLikelyReferenceSegment, splitReferenceSegment } from '@/lib/workflows/references'
|
||||
|
||||
describe('splitReferenceSegment', () => {
|
||||
it('should return leading and reference for simple segments', () => {
|
||||
const result = splitReferenceSegment('<block.output>')
|
||||
expect(result).toEqual({
|
||||
leading: '',
|
||||
reference: '<block.output>',
|
||||
})
|
||||
})
|
||||
|
||||
it('should separate comparator prefixes from reference', () => {
|
||||
const result = splitReferenceSegment('< <block2.output>')
|
||||
expect(result).toEqual({
|
||||
leading: '< ',
|
||||
reference: '<block2.output>',
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle <= comparator prefixes', () => {
|
||||
const result = splitReferenceSegment('<= <block2.output>')
|
||||
expect(result).toEqual({
|
||||
leading: '<= ',
|
||||
reference: '<block2.output>',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('isLikelyReferenceSegment', () => {
|
||||
it('should return true for regular references', () => {
|
||||
expect(isLikelyReferenceSegment('<block.output>')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for references after comparator', () => {
|
||||
expect(isLikelyReferenceSegment('< <block2.output>')).toBe(true)
|
||||
expect(isLikelyReferenceSegment('<= <block2.output>')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false when leading content is not comparator characters', () => {
|
||||
expect(isLikelyReferenceSegment('<foo<bar>')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -4,12 +4,47 @@ export const SYSTEM_REFERENCE_PREFIXES = new Set(['start', 'loop', 'parallel', '
|
||||
|
||||
const INVALID_REFERENCE_CHARS = /[+*/=<>!]/
|
||||
|
||||
export function isLikelyReferenceSegment(segment: string): boolean {
|
||||
const LEADING_REFERENCE_PATTERN = /^[<>=!\s]*$/
|
||||
|
||||
export function splitReferenceSegment(
|
||||
segment: string
|
||||
): { leading: string; reference: string } | null {
|
||||
if (!segment.startsWith('<') || !segment.endsWith('>')) {
|
||||
return null
|
||||
}
|
||||
|
||||
const lastOpenBracket = segment.lastIndexOf('<')
|
||||
if (lastOpenBracket === -1) {
|
||||
return null
|
||||
}
|
||||
|
||||
const leading = lastOpenBracket > 0 ? segment.slice(0, lastOpenBracket) : ''
|
||||
const reference = segment.slice(lastOpenBracket)
|
||||
|
||||
if (!reference.startsWith('<') || !reference.endsWith('>')) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { leading, reference }
|
||||
}
|
||||
|
||||
export function isLikelyReferenceSegment(segment: string): boolean {
|
||||
const split = splitReferenceSegment(segment)
|
||||
if (!split) {
|
||||
return false
|
||||
}
|
||||
|
||||
const inner = segment.slice(1, -1)
|
||||
const { leading, reference } = split
|
||||
|
||||
if (leading && !LEADING_REFERENCE_PATTERN.test(leading)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const inner = reference.slice(1, -1)
|
||||
|
||||
if (!inner) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (inner.startsWith(' ')) {
|
||||
return false
|
||||
@@ -55,18 +90,29 @@ export function extractReferencePrefixes(value: string): Array<{ raw: string; pr
|
||||
const references: Array<{ raw: string; prefix: string }> = []
|
||||
|
||||
for (const match of matches) {
|
||||
if (!isLikelyReferenceSegment(match)) {
|
||||
const split = splitReferenceSegment(match)
|
||||
if (!split) {
|
||||
continue
|
||||
}
|
||||
|
||||
const inner = match.slice(1, -1)
|
||||
if (split.leading && !LEADING_REFERENCE_PATTERN.test(split.leading)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const referenceSegment = split.reference
|
||||
|
||||
if (!isLikelyReferenceSegment(referenceSegment)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const inner = referenceSegment.slice(1, -1)
|
||||
const [rawPrefix] = inner.split('.')
|
||||
if (!rawPrefix) {
|
||||
continue
|
||||
}
|
||||
|
||||
const normalized = normalizeBlockName(rawPrefix)
|
||||
references.push({ raw: match, prefix: normalized })
|
||||
references.push({ raw: referenceSegment, prefix: normalized })
|
||||
}
|
||||
|
||||
return references
|
||||
|
||||
Reference in New Issue
Block a user