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:
Siddharth Ganesan
2025-11-07 18:03:36 -08:00
committed by GitHub
parent a73e2aaa8b
commit f62568efc7
10 changed files with 293 additions and 40 deletions

View File

@@ -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'

View File

@@ -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, '&lt;').replace(/>/g, '&gt;')
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',

View File

@@ -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)

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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)
})
})

View File

@@ -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,

View File

@@ -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`, {

View 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)
})
})

View File

@@ -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