This commit is contained in:
2025-12-01 17:21:38 +08:00
parent 32fee2b8ab
commit fab8c13cb3
7511 changed files with 996300 additions and 0 deletions

View File

@@ -0,0 +1,297 @@
'use client'
import type { FC, ReactNode } from 'react'
import React, { useCallback, useMemo } from 'react'
import { RiDeleteBinLine } from '@remixicon/react'
import Input from '@/app/components/base/input'
import Checkbox from '@/app/components/base/checkbox'
import { SimpleSelect } from '@/app/components/base/select'
import { replaceSpaceWithUnderscoreInVarNameInput } from '@/utils/var'
import cn from '@/utils/classnames'
// Tiny utility to judge whether a cell value is effectively present
const isPresent = (v: unknown): boolean => {
if (typeof v === 'string') return v.trim() !== ''
return !(v === '' || v === null || v === undefined || v === false)
}
// Column configuration types for table components
export type ColumnType = 'input' | 'select' | 'switch' | 'custom'
export type SelectOption = {
name: string
value: string
}
export type ColumnConfig = {
key: string
title: string
type: ColumnType
width?: string // CSS class for width (e.g., 'w-1/2', 'w-[140px]')
placeholder?: string
options?: SelectOption[] // For select type
render?: (value: unknown, row: GenericTableRow, index: number, onChange: (value: unknown) => void) => ReactNode
required?: boolean
}
export type GenericTableRow = {
[key: string]: unknown
}
type GenericTableProps = {
title: string
columns: ColumnConfig[]
data: GenericTableRow[]
onChange: (data: GenericTableRow[]) => void
readonly?: boolean
placeholder?: string
emptyRowData: GenericTableRow // Template for new empty rows
className?: string
showHeader?: boolean // Whether to show column headers
}
// Internal type for stable mapping between rendered rows and data indices
type DisplayRow = {
row: GenericTableRow
dataIndex: number | null // null indicates the trailing UI-only row
isVirtual: boolean // whether this row is the extra empty row for adding new items
}
const GenericTable: FC<GenericTableProps> = ({
title,
columns,
data,
onChange,
readonly = false,
placeholder,
emptyRowData,
className,
showHeader = false,
}) => {
// Build the rows to display while keeping a stable mapping to original data
const displayRows = useMemo<DisplayRow[]>(() => {
// Helper to check empty
const isEmptyRow = (r: GenericTableRow) =>
Object.values(r).every(v => v === '' || v === null || v === undefined || v === false)
if (readonly)
return data.map((r, i) => ({ row: r, dataIndex: i, isVirtual: false }))
const hasData = data.length > 0
const rows: DisplayRow[] = []
if (!hasData) {
// Initialize with exactly one empty row when there is no data
rows.push({ row: { ...emptyRowData }, dataIndex: null, isVirtual: true })
return rows
}
// Add configured rows, hide intermediate empty ones, keep mapping
data.forEach((r, i) => {
const isEmpty = isEmptyRow(r)
// Skip empty rows except the very last configured row
if (isEmpty && i < data.length - 1)
return
rows.push({ row: r, dataIndex: i, isVirtual: false })
})
// If the last configured row has content, append a trailing empty row
const lastHasContent = !isEmptyRow(data[data.length - 1])
if (lastHasContent)
rows.push({ row: { ...emptyRowData }, dataIndex: null, isVirtual: true })
return rows
}, [data, emptyRowData, readonly])
const removeRow = useCallback((dataIndex: number) => {
if (readonly) return
if (dataIndex < 0 || dataIndex >= data.length) return // ignore virtual rows
const newData = data.filter((_, i) => i !== dataIndex)
onChange(newData)
}, [data, readonly, onChange])
const updateRow = useCallback((dataIndex: number | null, key: string, value: unknown) => {
if (readonly) return
if (dataIndex !== null && dataIndex < data.length) {
// Editing existing configured row
const newData = [...data]
newData[dataIndex] = { ...newData[dataIndex], [key]: value }
onChange(newData)
return
}
// Editing the trailing UI-only empty row: create a new configured row
const newRow = { ...emptyRowData, [key]: value }
const next = [...data, newRow]
onChange(next)
}, [data, emptyRowData, onChange, readonly])
// Determine the primary identifier column just once
const primaryKey = useMemo(() => (
columns.find(col => col.key === 'key' || col.key === 'name')?.key ?? 'key'
), [columns])
const renderCell = (column: ColumnConfig, row: GenericTableRow, dataIndex: number | null) => {
const value = row[column.key]
const handleChange = (newValue: unknown) => updateRow(dataIndex, column.key, newValue)
switch (column.type) {
case 'input':
return (
<Input
value={(value as string) || ''}
onChange={(e) => {
// Format variable names (replace spaces with underscores)
if (column.key === 'key' || column.key === 'name')
replaceSpaceWithUnderscoreInVarNameInput(e.target)
handleChange(e.target.value)
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
e.currentTarget.blur()
}
}}
placeholder={column.placeholder}
disabled={readonly}
wrapperClassName="w-full min-w-0"
className={cn(
// Ghost/inline style: looks like plain text until focus/hover
'h-6 rounded-none border-0 bg-transparent px-0 py-0 shadow-none',
'hover:border-transparent hover:bg-transparent focus:border-transparent focus:bg-transparent',
'system-sm-regular text-text-secondary placeholder:text-text-quaternary',
)}
/>
)
case 'select':
return (
<SimpleSelect
items={column.options || []}
defaultValue={value as string | undefined}
onSelect={item => handleChange(item.value)}
disabled={readonly}
placeholder={column.placeholder}
hideChecked={false}
notClearable={true}
// wrapper provides compact height, trigger is transparent like text
wrapperClassName="h-6 w-full min-w-0"
className={cn(
'h-6 rounded-none bg-transparent pl-0 pr-6 text-text-secondary',
'hover:bg-transparent focus-visible:bg-transparent group-hover/simple-select:bg-transparent',
)}
optionWrapClassName="w-26 min-w-26 z-[60] -ml-3"
/>
)
case 'switch':
return (
<div className="flex h-7 items-center">
<Checkbox
id={`${column.key}-${String(dataIndex ?? 'v')}`}
checked={Boolean(value)}
onCheck={() => handleChange(!value)}
disabled={readonly}
/>
</div>
)
case 'custom':
return column.render ? column.render(value, row, (dataIndex ?? -1), handleChange) : null
default:
return null
}
}
const renderTable = () => {
return (
<div className="rounded-lg border border-divider-regular">
{showHeader && (
<div className="system-xs-medium-uppercase flex h-7 items-center leading-7 text-text-tertiary">
{columns.map((column, index) => (
<div
key={column.key}
className={cn(
'h-full pl-3',
column.width && column.width.startsWith('w-') ? 'shrink-0' : 'flex-1',
column.width,
// Add right border except for last column
index < columns.length - 1 && 'border-r border-divider-regular',
)}
>
{column.title}
</div>
))}
</div>
)}
<div className="divide-y divide-divider-subtle">
{displayRows.map(({ row, dataIndex, isVirtual: _isVirtual }, renderIndex) => {
const rowKey = `row-${renderIndex}`
// Check if primary identifier column has content
const primaryValue = row[primaryKey]
const hasContent = isPresent(primaryValue)
return (
<div
key={rowKey}
className={cn(
'group relative flex border-t border-divider-regular',
hasContent ? 'hover:bg-state-destructive-hover' : 'hover:bg-state-base-hover',
)}
style={{ minHeight: '28px' }}
>
{columns.map((column, columnIndex) => (
<div
key={column.key}
className={cn(
'shrink-0 pl-3',
column.width,
// Add right border except for last column
columnIndex < columns.length - 1 && 'border-r border-divider-regular',
)}
>
{renderCell(column, row, dataIndex)}
</div>
))}
{!readonly && dataIndex !== null && hasContent && (
<div className="absolute right-2 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100">
<button
type="button"
onClick={() => removeRow(dataIndex)}
className="p-1"
aria-label="Delete row"
>
<RiDeleteBinLine className="h-3.5 w-3.5 text-text-destructive" />
</button>
</div>
)}
</div>
)
})}
</div>
</div>
)
}
// Show placeholder only when readonly and there is no data configured
const showPlaceholder = readonly && data.length === 0
return (
<div className={className}>
<div className="mb-3 flex items-center justify-between">
<h4 className="system-sm-semibold-uppercase text-text-secondary">{title}</h4>
</div>
{showPlaceholder ? (
<div className="flex h-7 items-center justify-center rounded-lg border border-divider-regular bg-components-panel-bg text-xs font-normal leading-[18px] text-text-quaternary">
{placeholder}
</div>
) : (
renderTable()
)}
</div>
)
}
export default React.memo(GenericTable)

View File

@@ -0,0 +1,78 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import GenericTable from './generic-table'
import type { ColumnConfig, GenericTableRow } from './generic-table'
import type { WebhookHeader } from '../types'
type HeaderTableProps = {
readonly?: boolean
headers?: WebhookHeader[]
onChange: (headers: WebhookHeader[]) => void
}
const HeaderTable: FC<HeaderTableProps> = ({
readonly = false,
headers = [],
onChange,
}) => {
const { t } = useTranslation()
// Define columns for header table - matching prototype design
const columns: ColumnConfig[] = [
{
key: 'name',
title: t('workflow.nodes.triggerWebhook.varName'),
type: 'input',
width: 'flex-1',
placeholder: t('workflow.nodes.triggerWebhook.varNamePlaceholder'),
},
{
key: 'required',
title: t('workflow.nodes.triggerWebhook.required'),
type: 'switch',
width: 'w-[88px]',
},
]
// No default prefilled row; table initializes with one empty row
// Empty row template for new rows
const emptyRowData: GenericTableRow = {
name: '',
required: false,
}
// Convert WebhookHeader[] to GenericTableRow[]
const tableData: GenericTableRow[] = headers.map(header => ({
name: header.name,
required: header.required,
}))
// Handle data changes
const handleDataChange = (data: GenericTableRow[]) => {
const newHeaders: WebhookHeader[] = data
.filter(row => row.name && typeof row.name === 'string' && row.name.trim() !== '')
.map(row => ({
name: (row.name as string) || '',
required: !!row.required,
}))
onChange(newHeaders)
}
return (
<GenericTable
title="Header Parameters"
columns={columns}
data={tableData}
onChange={handleDataChange}
readonly={readonly}
placeholder={t('workflow.nodes.triggerWebhook.noHeaders')}
emptyRowData={emptyRowData}
showHeader={true}
/>
)
}
export default React.memo(HeaderTable)

View File

@@ -0,0 +1,57 @@
'use client'
import type { FC } from 'react'
import React, { useRef } from 'react'
import cn from '@/utils/classnames'
type ParagraphInputProps = {
value: string
onChange: (value: string) => void
placeholder?: string
disabled?: boolean
className?: string
}
const ParagraphInput: FC<ParagraphInputProps> = ({
value,
onChange,
placeholder,
disabled = false,
className,
}) => {
const textareaRef = useRef<HTMLTextAreaElement>(null)
const lines = value ? value.split('\n') : ['']
const lineCount = Math.max(3, lines.length)
return (
<div className={cn('rounded-xl bg-components-input-bg-normal px-3 pb-2 pt-3', className)}>
<div className="relative">
<div className="pointer-events-none absolute left-0 top-0 flex flex-col">
{Array.from({ length: lineCount }, (_, index) => (
<span
key={index}
className="flex h-[20px] select-none items-center font-mono text-xs leading-[20px] text-text-quaternary"
>
{String(index + 1).padStart(2, '0')}
</span>
))}
</div>
<textarea
ref={textareaRef}
value={value}
onChange={e => onChange(e.target.value)}
placeholder={placeholder}
disabled={disabled}
className="w-full resize-none border-0 bg-transparent pl-6 font-mono text-xs leading-[20px] text-text-secondary outline-none placeholder:text-text-quaternary"
style={{
minHeight: `${Math.max(3, lineCount) * 20}px`,
lineHeight: '20px',
}}
rows={Math.max(3, lineCount)}
/>
</div>
</div>
)
}
export default React.memo(ParagraphInput)

View File

@@ -0,0 +1,112 @@
'use client'
import type { FC } from 'react'
import React, { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import GenericTable from './generic-table'
import type { ColumnConfig, GenericTableRow } from './generic-table'
import type { WebhookParameter } from '../types'
import { createParameterTypeOptions, normalizeParameterType } from '../utils/parameter-type-utils'
import { VarType } from '@/app/components/workflow/types'
type ParameterTableProps = {
title: string
parameters: WebhookParameter[]
onChange: (params: WebhookParameter[]) => void
readonly?: boolean
placeholder?: string
contentType?: string
}
const ParameterTable: FC<ParameterTableProps> = ({
title,
parameters,
onChange,
readonly,
placeholder,
contentType,
}) => {
const { t } = useTranslation()
// Memoize typeOptions to prevent unnecessary re-renders that cause SimpleSelect state resets
const typeOptions = useMemo(() =>
createParameterTypeOptions(contentType),
[contentType],
)
// Define columns based on component type - matching prototype design
const columns: ColumnConfig[] = [
{
key: 'key',
title: t('workflow.nodes.triggerWebhook.varName'),
type: 'input',
width: 'flex-1',
placeholder: t('workflow.nodes.triggerWebhook.varNamePlaceholder'),
},
{
key: 'type',
title: t('workflow.nodes.triggerWebhook.varType'),
type: 'select',
width: 'w-[120px]',
placeholder: t('workflow.nodes.triggerWebhook.varType'),
options: typeOptions,
},
{
key: 'required',
title: t('workflow.nodes.triggerWebhook.required'),
type: 'switch',
width: 'w-[88px]',
},
]
// Choose sensible default type for new rows according to content type
const defaultTypeValue: VarType = typeOptions[0]?.value || 'string'
// Empty row template for new rows
const emptyRowData: GenericTableRow = {
key: '',
type: defaultTypeValue,
required: false,
}
const tableData: GenericTableRow[] = parameters.map(param => ({
key: param.name,
type: param.type,
required: param.required,
}))
const handleDataChange = (data: GenericTableRow[]) => {
// For text/plain, enforce single text body semantics: keep only first non-empty row and force string type
// For application/octet-stream, enforce single file body semantics: keep only first non-empty row and force file type
const isTextPlain = (contentType || '').toLowerCase() === 'text/plain'
const isOctetStream = (contentType || '').toLowerCase() === 'application/octet-stream'
const normalized = data
.filter(row => typeof row.key === 'string' && (row.key as string).trim() !== '')
.map(row => ({
name: String(row.key),
type: isTextPlain ? VarType.string : isOctetStream ? VarType.file : normalizeParameterType((row.type as string)),
required: Boolean(row.required),
}))
const newParams: WebhookParameter[] = (isTextPlain || isOctetStream)
? normalized.slice(0, 1)
: normalized
onChange(newParams)
}
return (
<GenericTable
title={title}
columns={columns}
data={tableData}
onChange={handleDataChange}
readonly={readonly}
placeholder={placeholder || t('workflow.nodes.triggerWebhook.noParameters')}
emptyRowData={emptyRowData}
showHeader={true}
/>
)
}
export default ParameterTable

View File

@@ -0,0 +1,64 @@
import { BlockEnum } from '../../types'
import type { NodeDefault } from '../../types'
import { genNodeMetaData } from '../../utils'
import type { WebhookTriggerNodeType } from './types'
import { isValidParameterType } from './utils/parameter-type-utils'
import { createWebhookRawVariable } from './utils/raw-variable'
const metaData = genNodeMetaData({
sort: 3,
type: BlockEnum.TriggerWebhook,
helpLinkUri: 'webhook-trigger',
isStart: true,
})
const nodeDefault: NodeDefault<WebhookTriggerNodeType> = {
metaData,
defaultValue: {
webhook_url: '',
method: 'POST',
content_type: 'application/json',
headers: [],
params: [],
body: [],
async_mode: true,
status_code: 200,
response_body: '',
variables: [createWebhookRawVariable()],
},
checkValid(payload: WebhookTriggerNodeType, t: any) {
// Require webhook_url to be configured
if (!payload.webhook_url || payload.webhook_url.trim() === '') {
return {
isValid: false,
errorMessage: t('workflow.nodes.triggerWebhook.validation.webhookUrlRequired'),
}
}
// Validate parameter types for params and body
const parametersWithTypes = [
...(payload.params || []),
...(payload.body || []),
]
for (const param of parametersWithTypes) {
// Validate parameter type is valid
if (!isValidParameterType(param.type)) {
return {
isValid: false,
errorMessage: t('workflow.nodes.triggerWebhook.validation.invalidParameterType', {
name: param.name,
type: param.type,
}),
}
}
}
return {
isValid: true,
errorMessage: '',
}
},
}
export default nodeDefault

View File

@@ -0,0 +1,25 @@
import type { FC } from 'react'
import React from 'react'
import type { WebhookTriggerNodeType } from './types'
import type { NodeProps } from '@/app/components/workflow/types'
const Node: FC<NodeProps<WebhookTriggerNodeType>> = ({
data,
}) => {
return (
<div className="mb-1 px-3 py-1">
<div className="mb-1 text-[10px] font-medium uppercase tracking-wide text-text-tertiary">
URL
</div>
<div className="flex h-[26px] items-center rounded-md bg-workflow-block-parma-bg px-2 text-xs text-text-secondary">
<div className="w-0 grow">
<div className="truncate" title={data.webhook_url || '--'}>
{data.webhook_url || '--'}
</div>
</div>
</div>
</div>
)
}
export default React.memo(Node)

View File

@@ -0,0 +1,240 @@
import type { FC } from 'react'
import React, { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { HttpMethod, WebhookTriggerNodeType } from './types'
import useConfig from './use-config'
import ParameterTable from './components/parameter-table'
import HeaderTable from './components/header-table'
import ParagraphInput from './components/paragraph-input'
import { OutputVariablesContent } from './utils/render-output-vars'
import Field from '@/app/components/workflow/nodes/_base/components/field'
import Split from '@/app/components/workflow/nodes/_base/components/split'
import OutputVars from '@/app/components/workflow/nodes/_base/components/output-vars'
import type { NodePanelProps } from '@/app/components/workflow/types'
import InputWithCopy from '@/app/components/base/input-with-copy'
import { InputNumber } from '@/app/components/base/input-number'
import { SimpleSelect } from '@/app/components/base/select'
import Toast from '@/app/components/base/toast'
import Tooltip from '@/app/components/base/tooltip'
import copy from 'copy-to-clipboard'
import { isPrivateOrLocalAddress } from '@/utils/urlValidation'
const i18nPrefix = 'workflow.nodes.triggerWebhook'
const HTTP_METHODS = [
{ name: 'GET', value: 'GET' },
{ name: 'POST', value: 'POST' },
{ name: 'PUT', value: 'PUT' },
{ name: 'DELETE', value: 'DELETE' },
{ name: 'PATCH', value: 'PATCH' },
{ name: 'HEAD', value: 'HEAD' },
]
const CONTENT_TYPES = [
{ name: 'application/json', value: 'application/json' },
{ name: 'application/x-www-form-urlencoded', value: 'application/x-www-form-urlencoded' },
{ name: 'text/plain', value: 'text/plain' },
{ name: 'application/octet-stream', value: 'application/octet-stream' },
{ name: 'multipart/form-data', value: 'multipart/form-data' },
]
const Panel: FC<NodePanelProps<WebhookTriggerNodeType>> = ({
id,
data,
}) => {
const { t } = useTranslation()
const [debugUrlCopied, setDebugUrlCopied] = React.useState(false)
const [outputVarsCollapsed, setOutputVarsCollapsed] = useState(false)
const {
readOnly,
inputs,
handleMethodChange,
handleContentTypeChange,
handleHeadersChange,
handleParamsChange,
handleBodyChange,
handleStatusCodeChange,
handleStatusCodeBlur,
handleResponseBodyChange,
generateWebhookUrl,
} = useConfig(id, data)
// Ensure we only attempt to generate URL once for a newly created node without url
const hasRequestedUrlRef = useRef(false)
useEffect(() => {
if (!readOnly && !inputs.webhook_url && !hasRequestedUrlRef.current) {
hasRequestedUrlRef.current = true
void generateWebhookUrl()
}
}, [readOnly, inputs.webhook_url, generateWebhookUrl])
return (
<div className='mt-2'>
<div className='space-y-4 px-4 pb-3 pt-2'>
{/* Webhook URL Section */}
<Field title={t(`${i18nPrefix}.webhookUrl`)}>
<div className="space-y-1">
<div className="flex gap-1" style={{ height: '32px' }}>
<div className="w-26 shrink-0">
<SimpleSelect
items={HTTP_METHODS}
defaultValue={inputs.method}
onSelect={item => handleMethodChange(item.value as HttpMethod)}
disabled={readOnly}
className="h-8 pr-8 text-sm"
wrapperClassName="h-8"
optionWrapClassName="w-26 min-w-26 z-[5]"
allowSearch={false}
notClearable={true}
/>
</div>
<div className="flex-1" style={{ width: '284px' }}>
<InputWithCopy
value={inputs.webhook_url || ''}
placeholder={t(`${i18nPrefix}.webhookUrlPlaceholder`)}
readOnly
onCopy={() => {
Toast.notify({
type: 'success',
message: t(`${i18nPrefix}.urlCopied`),
})
}}
/>
</div>
</div>
{inputs.webhook_debug_url && (
<div className="space-y-2">
<Tooltip
popupContent={debugUrlCopied ? t(`${i18nPrefix}.debugUrlCopied`) : t(`${i18nPrefix}.debugUrlCopy`)}
popupClassName="system-xs-regular text-text-primary bg-components-tooltip-bg border border-components-panel-border shadow-lg backdrop-blur-sm rounded-md px-1.5 py-1"
position="top"
offset={{ mainAxis: -20 }}
needsDelay={true}
>
<div
className="flex cursor-pointer gap-1.5 rounded-lg px-1 py-1.5 transition-colors"
style={{ width: '368px', height: '38px' }}
onClick={() => {
copy(inputs.webhook_debug_url || '')
setDebugUrlCopied(true)
setTimeout(() => setDebugUrlCopied(false), 2000)
}}
>
<div className="mt-0.5 w-0.5 bg-divider-regular" style={{ height: '28px' }}></div>
<div className="flex-1" style={{ width: '352px', height: '32px' }}>
<div className="text-xs leading-4 text-text-tertiary">
{t(`${i18nPrefix}.debugUrlTitle`)}
</div>
<div className="truncate text-xs leading-4 text-text-primary">
{inputs.webhook_debug_url}
</div>
</div>
</div>
</Tooltip>
{isPrivateOrLocalAddress(inputs.webhook_debug_url) && (
<div className="system-xs-regular mt-1 px-0 py-[2px] text-text-warning">
{t(`${i18nPrefix}.debugUrlPrivateAddressWarning`)}
</div>
)}
</div>
)}
</div>
</Field>
{/* Content Type */}
<Field title={t(`${i18nPrefix}.contentType`)}>
<div className="w-full">
<SimpleSelect
items={CONTENT_TYPES}
defaultValue={inputs.content_type}
onSelect={item => handleContentTypeChange(item.value as string)}
disabled={readOnly}
className="h-8 text-sm"
wrapperClassName="h-8"
optionWrapClassName="min-w-48 z-[5]"
allowSearch={false}
notClearable={true}
/>
</div>
</Field>
{/* Query Parameters */}
<ParameterTable
readonly={readOnly}
title="Query Parameters"
parameters={inputs.params}
onChange={handleParamsChange}
placeholder={t(`${i18nPrefix}.noQueryParameters`)}
/>
{/* Header Parameters */}
<HeaderTable
readonly={readOnly}
headers={inputs.headers}
onChange={handleHeadersChange}
/>
{/* Request Body Parameters */}
<ParameterTable
readonly={readOnly}
title="Request Body Parameters"
parameters={inputs.body}
onChange={handleBodyChange}
placeholder={t(`${i18nPrefix}.noBodyParameters`)}
contentType={inputs.content_type}
/>
<Split />
{/* Response Configuration */}
<Field title={t(`${i18nPrefix}.responseConfiguration`)}>
<div className="space-y-3">
<div className="flex items-center justify-between">
<label className="system-sm-medium text-text-tertiary">
{t(`${i18nPrefix}.statusCode`)}
</label>
<InputNumber
value={inputs.status_code}
onChange={(value) => {
handleStatusCodeChange(value || 200)
}}
disabled={readOnly}
wrapClassName="w-[120px]"
className="h-8"
defaultValue={200}
onBlur={() => {
handleStatusCodeBlur(inputs.status_code)
}}
/>
</div>
<div>
<label className="system-sm-medium mb-2 block text-text-tertiary">
{t(`${i18nPrefix}.responseBody`)}
</label>
<ParagraphInput
value={inputs.response_body}
onChange={handleResponseBodyChange}
placeholder={t(`${i18nPrefix}.responseBodyPlaceholder`)}
disabled={readOnly}
/>
</div>
</div>
</Field>
</div>
<Split />
<div className=''>
<OutputVars
collapsed={outputVarsCollapsed}
onCollapse={setOutputVarsCollapsed}
>
<OutputVariablesContent variables={inputs.variables} />
</OutputVars>
</div>
</div>
)
}
export default Panel

View File

@@ -0,0 +1,35 @@
import type { CommonNodeType, VarType, Variable } from '@/app/components/workflow/types'
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD'
export type ArrayElementType = 'string' | 'number' | 'boolean' | 'object'
export const getArrayElementType = (arrayType: `array[${ArrayElementType}]`): ArrayElementType => {
const match = arrayType.match(/^array\[(.+)\]$/)
return (match?.[1] as ArrayElementType) || 'string'
}
export type WebhookParameter = {
name: string
type: VarType
required: boolean
}
export type WebhookHeader = {
name: string
required: boolean
}
export type WebhookTriggerNodeType = CommonNodeType & {
webhook_url?: string
webhook_debug_url?: string
method: HttpMethod
content_type: string
headers: WebhookHeader[]
params: WebhookParameter[]
body: WebhookParameter[]
async_mode: boolean
status_code: number
response_body: string
variables: Variable[]
}

View File

@@ -0,0 +1,251 @@
import { useCallback } from 'react'
import { produce } from 'immer'
import { useTranslation } from 'react-i18next'
import type { HttpMethod, WebhookHeader, WebhookParameter, WebhookTriggerNodeType } from './types'
import { useNodesReadOnly, useWorkflow } from '@/app/components/workflow/hooks'
import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
import { useStore as useAppStore } from '@/app/components/app/store'
import { fetchWebhookUrl } from '@/service/apps'
import type { Variable } from '@/app/components/workflow/types'
import { VarType } from '@/app/components/workflow/types'
import Toast from '@/app/components/base/toast'
import { checkKeys, hasDuplicateStr } from '@/utils/var'
import { WEBHOOK_RAW_VARIABLE_NAME } from './utils/raw-variable'
const useConfig = (id: string, payload: WebhookTriggerNodeType) => {
const { t } = useTranslation()
const { nodesReadOnly: readOnly } = useNodesReadOnly()
const { inputs, setInputs } = useNodeCrud<WebhookTriggerNodeType>(id, payload)
const appId = useAppStore.getState().appDetail?.id
const { isVarUsedInNodes, removeUsedVarInNodes } = useWorkflow()
const handleMethodChange = useCallback((method: HttpMethod) => {
setInputs(produce(inputs, (draft) => {
draft.method = method
}))
}, [inputs, setInputs])
const handleContentTypeChange = useCallback((contentType: string) => {
setInputs(produce(inputs, (draft) => {
const previousContentType = draft.content_type
draft.content_type = contentType
// If the content type changes, reset body parameters and their variables, as the variable types might differ.
// However, we could consider retaining variables that are compatible with the new content type later.
if (previousContentType !== contentType) {
draft.body = []
if (draft.variables) {
const bodyVariables = draft.variables.filter(v => v.label === 'body')
bodyVariables.forEach((v) => {
if (isVarUsedInNodes([id, v.variable]))
removeUsedVarInNodes([id, v.variable])
})
draft.variables = draft.variables.filter(v => v.label !== 'body')
}
}
}))
}, [inputs, setInputs, id, isVarUsedInNodes, removeUsedVarInNodes])
const syncVariablesInDraft = useCallback((
draft: WebhookTriggerNodeType,
newData: (WebhookParameter | WebhookHeader)[],
sourceType: 'param' | 'header' | 'body',
) => {
if (!draft.variables)
draft.variables = []
const sanitizedEntries = newData.map(item => ({
item,
sanitizedName: sourceType === 'header' ? item.name.replace(/-/g, '_') : item.name,
}))
const hasReservedConflict = sanitizedEntries.some(entry => entry.sanitizedName === WEBHOOK_RAW_VARIABLE_NAME)
if (hasReservedConflict) {
Toast.notify({
type: 'error',
message: t('appDebug.varKeyError.keyAlreadyExists', {
key: t('appDebug.variableConfig.varName'),
}),
})
return false
}
const existingOtherVarNames = new Set(
draft.variables
.filter(v => v.label !== sourceType && v.variable !== WEBHOOK_RAW_VARIABLE_NAME)
.map(v => v.variable),
)
const crossScopeConflict = sanitizedEntries.find(entry => existingOtherVarNames.has(entry.sanitizedName))
if (crossScopeConflict) {
Toast.notify({
type: 'error',
message: t('appDebug.varKeyError.keyAlreadyExists', {
key: crossScopeConflict.sanitizedName,
}),
})
return false
}
if(hasDuplicateStr(sanitizedEntries.map(entry => entry.sanitizedName))) {
Toast.notify({
type: 'error',
message: t('appDebug.varKeyError.keyAlreadyExists', {
key: t('appDebug.variableConfig.varName'),
}),
})
return false
}
for (const { sanitizedName } of sanitizedEntries) {
const { isValid, errorMessageKey } = checkKeys([sanitizedName], false)
if (!isValid) {
Toast.notify({
type: 'error',
message: t(`appDebug.varKeyError.${errorMessageKey}`, {
key: t('appDebug.variableConfig.varName'),
}),
})
return false
}
}
// Create set of new variable names for this source
const newVarNames = new Set(sanitizedEntries.map(entry => entry.sanitizedName))
// Find variables from current source that will be deleted and clean up references
draft.variables
.filter(v => v.label === sourceType && !newVarNames.has(v.variable))
.forEach((v) => {
// Clean up references if variable is used in other nodes
if (isVarUsedInNodes([id, v.variable]))
removeUsedVarInNodes([id, v.variable])
})
// Remove variables that no longer exist in newData for this specific source type
draft.variables = draft.variables.filter((v) => {
// Keep variables from other sources
if (v.label !== sourceType) return true
return newVarNames.has(v.variable)
})
// Add or update variables
sanitizedEntries.forEach(({ item, sanitizedName }) => {
const existingVarIndex = draft.variables.findIndex(v => v.variable === sanitizedName)
const inputVarType = 'type' in item
? item.type
: VarType.string // Default to string for headers
const newVar: Variable = {
value_type: inputVarType,
label: sourceType, // Use sourceType as label to identify source
variable: sanitizedName,
value_selector: [],
required: item.required,
}
if (existingVarIndex >= 0)
draft.variables[existingVarIndex] = newVar
else
draft.variables.push(newVar)
})
return true
}, [t, id, isVarUsedInNodes, removeUsedVarInNodes])
const handleParamsChange = useCallback((params: WebhookParameter[]) => {
setInputs(produce(inputs, (draft) => {
draft.params = params
syncVariablesInDraft(draft, params, 'param')
}))
}, [inputs, setInputs, syncVariablesInDraft])
const handleHeadersChange = useCallback((headers: WebhookHeader[]) => {
setInputs(produce(inputs, (draft) => {
draft.headers = headers
syncVariablesInDraft(draft, headers, 'header')
}))
}, [inputs, setInputs, syncVariablesInDraft])
const handleBodyChange = useCallback((body: WebhookParameter[]) => {
setInputs(produce(inputs, (draft) => {
draft.body = body
syncVariablesInDraft(draft, body, 'body')
}))
}, [inputs, setInputs, syncVariablesInDraft])
const handleAsyncModeChange = useCallback((asyncMode: boolean) => {
setInputs(produce(inputs, (draft) => {
draft.async_mode = asyncMode
}))
}, [inputs, setInputs])
const handleStatusCodeChange = useCallback((statusCode: number) => {
setInputs(produce(inputs, (draft) => {
draft.status_code = statusCode
}))
}, [inputs, setInputs])
const handleStatusCodeBlur = useCallback((statusCode: number) => {
// Only clamp when user finishes editing (on blur)
const clampedStatusCode = Math.min(Math.max(statusCode, 200), 399)
setInputs(produce(inputs, (draft) => {
draft.status_code = clampedStatusCode
}))
}, [inputs, setInputs])
const handleResponseBodyChange = useCallback((responseBody: string) => {
setInputs(produce(inputs, (draft) => {
draft.response_body = responseBody
}))
}, [inputs, setInputs])
const generateWebhookUrl = useCallback(async () => {
// Idempotency: if we already have a URL, just return it.
if (inputs.webhook_url && inputs.webhook_url.length > 0)
return
if (!appId)
return
try {
// Call backend to generate or fetch webhook url for this node
const response = await fetchWebhookUrl({ appId, nodeId: id })
const newInputs = produce(inputs, (draft) => {
draft.webhook_url = response.webhook_url
draft.webhook_debug_url = response.webhook_debug_url
})
setInputs(newInputs)
}
catch (error: unknown) {
// Fallback to mock URL when API is not ready or request fails
// Keep the UI unblocked and allow users to proceed in local/dev environments.
console.error('Failed to generate webhook URL:', error)
const newInputs = produce(inputs, (draft) => {
draft.webhook_url = ''
})
setInputs(newInputs)
}
}, [appId, id, inputs, setInputs])
return {
readOnly,
inputs,
setInputs,
handleMethodChange,
handleContentTypeChange,
handleHeadersChange,
handleParamsChange,
handleBodyChange,
handleAsyncModeChange,
handleStatusCodeChange,
handleStatusCodeBlur,
handleResponseBodyChange,
generateWebhookUrl,
}
}
export default useConfig

View File

@@ -0,0 +1,125 @@
import { VarType } from '@/app/components/workflow/types'
// Constants for better maintainability and reusability
const BASIC_TYPES = [VarType.string, VarType.number, VarType.boolean, VarType.object, VarType.file] as const
const ARRAY_ELEMENT_TYPES = [VarType.arrayString, VarType.arrayNumber, VarType.arrayBoolean, VarType.arrayObject] as const
// Generate all valid parameter types programmatically
const VALID_PARAMETER_TYPES: readonly VarType[] = [
...BASIC_TYPES,
...ARRAY_ELEMENT_TYPES,
] as const
// Type display name mappings
const TYPE_DISPLAY_NAMES: Record<VarType, string> = {
[VarType.string]: 'String',
[VarType.number]: 'Number',
[VarType.boolean]: 'Boolean',
[VarType.object]: 'Object',
[VarType.file]: 'File',
[VarType.arrayString]: 'Array[String]',
[VarType.arrayNumber]: 'Array[Number]',
[VarType.arrayBoolean]: 'Array[Boolean]',
[VarType.arrayObject]: 'Array[Object]',
[VarType.secret]: 'Secret',
[VarType.array]: 'Array',
'array[file]': 'Array[File]',
[VarType.any]: 'Any',
'array[any]': 'Array[Any]',
[VarType.integer]: 'Integer',
} as const
// Content type configurations
const CONTENT_TYPE_CONFIGS = {
'application/json': {
supportedTypes: [...BASIC_TYPES.filter(t => t !== 'file'), ...ARRAY_ELEMENT_TYPES],
description: 'JSON supports all types including arrays',
},
'text/plain': {
supportedTypes: [VarType.string] as const,
description: 'Plain text only supports string',
},
'application/x-www-form-urlencoded': {
supportedTypes: [VarType.string, VarType.number, VarType.boolean] as const,
description: 'Form data supports basic types',
},
'application/octet-stream': {
supportedTypes: [VarType.file] as const,
description: 'octet-stream supports only binary data',
},
'multipart/form-data': {
supportedTypes: [VarType.string, VarType.number, VarType.boolean, VarType.file] as const,
description: 'Multipart supports basic types plus files',
},
} as const
/**
* Type guard to check if a string is a valid parameter type
*/
export const isValidParameterType = (type: string): type is VarType => {
return (VALID_PARAMETER_TYPES as readonly string[]).includes(type)
}
export const normalizeParameterType = (input: string | undefined | null): VarType => {
if (!input || typeof input !== 'string')
return VarType.string
const trimmed = input.trim().toLowerCase()
if (trimmed === 'array[string]')
return VarType.arrayString
else if (trimmed === 'array[number]')
return VarType.arrayNumber
else if (trimmed === 'array[boolean]')
return VarType.arrayBoolean
else if (trimmed === 'array[object]')
return VarType.arrayObject
else if (trimmed === 'array')
// Migrate legacy 'array' type to 'array[string]'
return VarType.arrayString
else if (trimmed === 'number')
return VarType.number
else if (trimmed === 'boolean')
return VarType.boolean
else if (trimmed === 'object')
return VarType.object
else if (trimmed === 'file')
return VarType.file
return VarType.string
}
/**
* Gets display name for parameter types in UI components
*/
export const getParameterTypeDisplayName = (type: VarType): string => {
return TYPE_DISPLAY_NAMES[type]
}
/**
* Gets available parameter types based on content type
* Provides context-aware type filtering for different webhook content types
*/
export const getAvailableParameterTypes = (contentType?: string): VarType[] => {
if (!contentType)
return [VarType.string, VarType.number, VarType.boolean]
const normalizedContentType = (contentType || '').toLowerCase()
const configKey = normalizedContentType in CONTENT_TYPE_CONFIGS
? normalizedContentType as keyof typeof CONTENT_TYPE_CONFIGS
: 'application/json'
const config = CONTENT_TYPE_CONFIGS[configKey]
return [...config.supportedTypes]
}
/**
* Creates type options for UI select components
*/
export const createParameterTypeOptions = (contentType?: string) => {
const availableTypes = getAvailableParameterTypes(contentType)
return availableTypes.map(type => ({
name: getParameterTypeDisplayName(type),
value: type,
}))
}

View File

@@ -0,0 +1,12 @@
import { VarType, type Variable } from '@/app/components/workflow/types'
export const WEBHOOK_RAW_VARIABLE_NAME = '_webhook_raw'
export const WEBHOOK_RAW_VARIABLE_LABEL = 'raw'
export const createWebhookRawVariable = (): Variable => ({
variable: WEBHOOK_RAW_VARIABLE_NAME,
label: WEBHOOK_RAW_VARIABLE_LABEL,
value_type: VarType.object,
value_selector: [],
required: true,
})

View File

@@ -0,0 +1,75 @@
import type { FC } from 'react'
import React from 'react'
import type { Variable } from '@/app/components/workflow/types'
type OutputVariablesContentProps = {
variables?: Variable[]
}
// Define the display order for variable labels to match the table order in the UI
const LABEL_ORDER = { raw: 0, param: 1, header: 2, body: 3 } as const
const getLabelPrefix = (label: string): string => {
const prefixMap: Record<string, string> = {
raw: 'payload',
param: 'query_params',
header: 'header_params',
body: 'req_body_params',
}
return prefixMap[label] || label
}
type VarItemProps = {
prefix: string
name: string
type: string
}
const VarItem: FC<VarItemProps> = ({ prefix, name, type }) => {
return (
<div className='py-1'>
<div className='flex items-center leading-[18px]'>
<span className='code-sm-regular text-text-tertiary'>{prefix}.</span>
<span className='code-sm-semibold text-text-secondary'>{name}</span>
<span className='system-xs-regular ml-2 text-text-tertiary'>{type}</span>
</div>
</div>
)
}
export const OutputVariablesContent: FC<OutputVariablesContentProps> = ({ variables = [] }) => {
if (!variables || variables.length === 0) {
return (
<div className="system-sm-regular py-2 text-text-tertiary">
No output variables
</div>
)
}
// Sort variables by label to match the table display order: param → header → body
// Unknown labels are placed at the end (order value 999)
const sortedVariables = [...variables].sort((a, b) => {
const labelA = typeof a.label === 'string' ? a.label : ''
const labelB = typeof b.label === 'string' ? b.label : ''
return (LABEL_ORDER[labelA as keyof typeof LABEL_ORDER] || 999)
- (LABEL_ORDER[labelB as keyof typeof LABEL_ORDER] || 999)
})
return (
<div>
{sortedVariables.map((variable, index) => {
const label = typeof variable.label === 'string' ? variable.label : ''
const varName = typeof variable.variable === 'string' ? variable.variable : ''
return (
<VarItem
key={`${label}-${varName}-${index}`}
prefix={getLabelPrefix(label)}
name={varName}
type={variable.value_type || 'string'}
/>
)
})}
</div>
)
}