feat: webhook trigger frontend (#24311)

This commit is contained in:
cathy 2025-08-23 23:54:41 +08:00 committed by GitHub
parent a7b558b38b
commit 6b0d919dbd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 1184 additions and 31 deletions

View File

@ -0,0 +1,150 @@
import React from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import '@testing-library/jest-dom'
import InputWithCopy from './index'
// Mock the copy-to-clipboard library
jest.mock('copy-to-clipboard', () => jest.fn(() => true))
// Mock the i18n hook
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
'common.operation.copy': 'Copy',
'common.operation.copied': 'Copied',
'appOverview.overview.appInfo.embedded.copy': 'Copy',
'appOverview.overview.appInfo.embedded.copied': 'Copied',
}
return translations[key] || key
},
}),
}))
// Mock lodash-es debounce
jest.mock('lodash-es', () => ({
debounce: (fn: any) => fn,
}))
describe('InputWithCopy component', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('renders correctly with default props', () => {
const mockOnChange = jest.fn()
render(<InputWithCopy value="test value" onChange={mockOnChange} />)
const input = screen.getByDisplayValue('test value')
const copyButton = screen.getByRole('button')
expect(input).toBeInTheDocument()
expect(copyButton).toBeInTheDocument()
})
it('hides copy button when showCopyButton is false', () => {
const mockOnChange = jest.fn()
render(<InputWithCopy value="test value" onChange={mockOnChange} showCopyButton={false} />)
const input = screen.getByDisplayValue('test value')
const copyButton = screen.queryByRole('button')
expect(input).toBeInTheDocument()
expect(copyButton).not.toBeInTheDocument()
})
it('copies input value when copy button is clicked', async () => {
const copyToClipboard = require('copy-to-clipboard')
const mockOnChange = jest.fn()
render(<InputWithCopy value="test value" onChange={mockOnChange} />)
const copyButton = screen.getByRole('button')
fireEvent.click(copyButton)
expect(copyToClipboard).toHaveBeenCalledWith('test value')
})
it('copies custom value when copyValue prop is provided', async () => {
const copyToClipboard = require('copy-to-clipboard')
const mockOnChange = jest.fn()
render(<InputWithCopy value="display value" onChange={mockOnChange} copyValue="custom copy value" />)
const copyButton = screen.getByRole('button')
fireEvent.click(copyButton)
expect(copyToClipboard).toHaveBeenCalledWith('custom copy value')
})
it('calls onCopy callback when copy button is clicked', async () => {
const onCopyMock = jest.fn()
const mockOnChange = jest.fn()
render(<InputWithCopy value="test value" onChange={mockOnChange} onCopy={onCopyMock} />)
const copyButton = screen.getByRole('button')
fireEvent.click(copyButton)
expect(onCopyMock).toHaveBeenCalledWith('test value')
})
it('shows copied state after successful copy', async () => {
const mockOnChange = jest.fn()
render(<InputWithCopy value="test value" onChange={mockOnChange} />)
const copyButton = screen.getByRole('button')
fireEvent.click(copyButton)
// Hover over the button to trigger tooltip
fireEvent.mouseEnter(copyButton)
// Check if the tooltip shows "Copied" state
await waitFor(() => {
expect(screen.getByText('Copied')).toBeInTheDocument()
}, { timeout: 2000 })
})
it('passes through all input props correctly', () => {
const mockOnChange = jest.fn()
render(
<InputWithCopy
value="test value"
onChange={mockOnChange}
placeholder="Custom placeholder"
disabled
readOnly
className="custom-class"
/>,
)
const input = screen.getByDisplayValue('test value')
expect(input).toHaveAttribute('placeholder', 'Custom placeholder')
expect(input).toBeDisabled()
expect(input).toHaveAttribute('readonly')
expect(input).toHaveClass('custom-class')
})
it('handles empty value correctly', () => {
const copyToClipboard = require('copy-to-clipboard')
const mockOnChange = jest.fn()
render(<InputWithCopy value="" onChange={mockOnChange} />)
const input = screen.getByRole('textbox')
const copyButton = screen.getByRole('button')
expect(input).toBeInTheDocument()
expect(copyButton).toBeInTheDocument()
fireEvent.click(copyButton)
expect(copyToClipboard).toHaveBeenCalledWith('')
})
it('maintains focus on input after copy', async () => {
const mockOnChange = jest.fn()
render(<InputWithCopy value="test value" onChange={mockOnChange} />)
const input = screen.getByDisplayValue('test value')
const copyButton = screen.getByRole('button')
input.focus()
expect(input).toHaveFocus()
fireEvent.click(copyButton)
// Input should maintain focus after copy
expect(input).toHaveFocus()
})
})

View File

@ -0,0 +1,104 @@
'use client'
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { RiClipboardFill, RiClipboardLine } from '@remixicon/react'
import { debounce } from 'lodash-es'
import copy from 'copy-to-clipboard'
import type { InputProps } from '../input'
import Tooltip from '../tooltip'
import ActionButton from '../action-button'
import cn from '@/utils/classnames'
export type InputWithCopyProps = {
showCopyButton?: boolean
copyValue?: string // Value to copy, defaults to input value
onCopy?: (value: string) => void // Callback when copy is triggered
} & Omit<InputProps, 'showClearIcon' | 'onCopy'> // Remove conflicting props
const prefixEmbedded = 'appOverview.overview.appInfo.embedded'
const InputWithCopy = React.forwardRef<HTMLInputElement, InputWithCopyProps>((
{
showCopyButton = true,
copyValue,
onCopy,
value,
wrapperClassName,
...inputProps
},
ref,
) => {
const { t } = useTranslation()
const [isCopied, setIsCopied] = useState<boolean>(false)
// Determine what value to copy
const valueToString = typeof value === 'string' ? value : String(value || '')
const finalCopyValue = copyValue || valueToString
const onClickCopy = debounce(() => {
copy(finalCopyValue)
setIsCopied(true)
onCopy?.(finalCopyValue)
}, 100)
const onMouseLeave = debounce(() => {
setIsCopied(false)
}, 100)
useEffect(() => {
if (isCopied) {
const timeout = setTimeout(() => {
setIsCopied(false)
}, 2000)
return () => {
clearTimeout(timeout)
}
}
}, [isCopied])
return (
<div className={cn('relative w-full', wrapperClassName)}>
<input
ref={ref}
className={cn(
'w-full appearance-none border border-transparent bg-components-input-bg-normal py-[7px] text-components-input-text-filled caret-primary-600 outline-none placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs',
'radius-md system-sm-regular px-3',
showCopyButton && 'pr-8',
inputProps.disabled && 'cursor-not-allowed border-transparent bg-components-input-bg-disabled text-components-input-text-filled-disabled hover:border-transparent hover:bg-components-input-bg-disabled',
inputProps.className,
)}
value={value}
{...(({ size, ...rest }) => rest)(inputProps)}
/>
{showCopyButton && (
<div
className="absolute right-2 top-1/2 -translate-y-1/2"
onMouseLeave={onMouseLeave}
>
<Tooltip
popupContent={
(isCopied
? t(`${prefixEmbedded}.copied`)
: t(`${prefixEmbedded}.copy`)) || ''
}
>
<ActionButton
size="xs"
onClick={onClickCopy}
className="hover:bg-components-button-ghost-bg-hover"
>
{isCopied ? (
<RiClipboardFill className='h-3.5 w-3.5 text-text-tertiary' />
) : (
<RiClipboardLine className='h-3.5 w-3.5 text-text-tertiary' />
)}
</ActionButton>
</Tooltip>
</div>
)}
</div>
)
})
InputWithCopy.displayName = 'InputWithCopy'
export default InputWithCopy

View File

@ -0,0 +1,285 @@
'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 cn from '@/utils/classnames'
// 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,
}) => {
const DELETE_COL_PADDING_CLASS = 'pr-[56px]'
const DELETE_COL_WIDTH_CLASS = 'w-[56px]'
// 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])
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 => handleChange(e.target.value)}
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-tertiary',
)}
/>
)
case 'select':
return (
<SimpleSelect
items={column.options || []}
defaultValue={value as string | undefined}
onSelect={item => handleChange(item.value)}
disabled={readonly}
placeholder={column.placeholder}
// 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 px-0 text-text-secondary',
'hover:bg-transparent focus-visible:bg-transparent group-hover/simple-select:bg-transparent',
)}
optionWrapClassName="rounded-md"
notClearable
/>
)
case 'switch':
return (
<Checkbox
id={`${column.key}-${String(dataIndex ?? 'v')}`}
checked={Boolean(value)}
onCheck={() => handleChange(!value)}
disabled={readonly}
className="!h-4 !w-4 shadow-none"
/>
)
case 'custom':
return column.render ? column.render(value, row, (dataIndex ?? -1), handleChange) : null
default:
return null
}
}
const renderTable = () => {
return (
<div className="rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg shadow-xs">
{showHeader && (
<div
className={cn(
'flex items-center gap-2 border-b border-divider-subtle px-3 py-2',
!readonly && DELETE_COL_PADDING_CLASS,
)}
>
{columns.map(column => (
<div
key={column.key}
className={cn(
'text-xs uppercase text-text-tertiary',
column.width && column.width.startsWith('w-') ? 'shrink-0' : 'min-w-0 flex-1 overflow-hidden',
column.width,
)}
>
<span className="truncate">{column.title}</span>
</div>
))}
</div>
)}
<div className="divide-y divide-divider-subtle">
{displayRows.map(({ row, dataIndex, isVirtual }, renderIndex) => {
// Determine emptiness for UI-only controls visibility
const isEmpty = Object.values(row).every(value =>
value === '' || value === null || value === undefined || value === false,
)
const rowKey = `row-${renderIndex}`
return (
<div
key={rowKey}
className={cn(
'group relative flex items-center gap-2 px-3 py-1.5 hover:bg-components-panel-on-panel-item-bg-hover',
!readonly && DELETE_COL_PADDING_CLASS,
)}
>
{columns.map(column => (
<div
key={column.key}
className={cn(
'relative',
column.width && column.width.startsWith('w-') ? 'shrink-0' : 'min-w-0 flex-1',
column.width,
// Avoid children overflow when content is long in flexible columns
!(column.width && column.width.startsWith('w-')) && 'overflow-hidden',
)}
>
{renderCell(column, row, dataIndex)}
</div>
))}
{!readonly && data.length > 1 && !isEmpty && !isVirtual && (
<div
className={cn(
'pointer-events-none absolute inset-y-0 right-0 hidden items-center justify-end rounded-lg bg-gradient-to-l from-components-panel-on-panel-item-bg to-background-gradient-mask-transparent pr-2 group-hover:pointer-events-auto group-hover:flex',
DELETE_COL_WIDTH_CLASS,
)}
>
<button
type="button"
onClick={() => dataIndex !== null && removeRow(dataIndex)}
className="text-text-tertiary opacity-70 transition-colors hover:text-text-destructive hover:opacity-100"
aria-label="Delete row"
>
<RiDeleteBinLine className="h-3.5 w-3.5" />
</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="text-sm font-medium text-text-secondary">{title}</h4>
</div>
{showPlaceholder ? (
<div className="py-8 text-center text-sm text-text-tertiary">
{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: 'Variable Name',
type: 'input',
width: 'flex-1',
placeholder: 'Variable Name',
},
{
key: 'required',
title: 'Required',
type: 'switch',
width: 'w-[48px]',
},
]
// 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 && row.name.trim() !== '')
.map(row => ({
name: row.name || '',
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,107 @@
'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 { WebhookParam } from '../types'
type ParameterTableProps = {
title: string
parameters: WebhookParam[]
onChange: (params: WebhookParam[]) => void
readonly?: boolean
placeholder?: string
showType?: boolean
isRequestBody?: boolean // Special handling for request body parameters
}
const ParameterTable: FC<ParameterTableProps> = ({
title,
parameters,
onChange,
readonly,
placeholder,
showType = true,
isRequestBody = false,
}) => {
const { t } = useTranslation()
// Type options for request body parameters
const typeOptions = [
{ name: 'String', value: 'string' },
{ name: 'Number', value: 'number' },
{ name: 'Boolean', value: 'boolean' },
{ name: 'Array', value: 'array' },
{ name: 'Object', value: 'object' },
]
// Define columns based on component type - matching prototype design
const columns: ColumnConfig[] = [
{
key: 'key',
title: isRequestBody ? 'Name' : 'Variable Name',
type: 'input',
width: 'flex-1',
placeholder: isRequestBody ? 'Name' : 'Variable Name',
},
...(showType
? [{
key: 'type',
title: 'Type',
type: (isRequestBody ? 'select' : 'input') as ColumnConfig['type'],
width: 'w-[96px]',
placeholder: 'Type',
options: isRequestBody ? typeOptions : undefined,
}]
: []),
{
key: 'required',
title: 'Required',
type: 'switch',
width: 'w-[48px]',
},
]
// Empty row template for new rows
const emptyRowData: GenericTableRow = {
key: '',
type: '',
required: false,
}
// Convert WebhookParam[] to GenericTableRow[]
const tableData: GenericTableRow[] = parameters.map(param => ({
key: param.key,
type: param.type,
required: param.required,
value: param.value,
}))
const handleDataChange = (data: GenericTableRow[]) => {
const newParams: WebhookParam[] = data
.filter(row => typeof row.key === 'string' && (row.key as string).trim() !== '')
.map(row => ({
key: String(row.key),
type: (row.type as string) || 'string',
required: Boolean(row.required),
value: (row.value as string) || '',
}))
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

@ -2,16 +2,24 @@ import { BlockEnum } from '../../types'
import type { NodeDefault } from '../../types'
import type { WebhookTriggerNodeType } from './types'
import { ALL_CHAT_AVAILABLE_BLOCKS, ALL_COMPLETION_AVAILABLE_BLOCKS } from '@/app/components/workflow/blocks'
import { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types'
import type { DefaultValueForm } from '@/app/components/workflow/nodes/_base/components/error-handle/types'
const nodeDefault: NodeDefault<WebhookTriggerNodeType> = {
defaultValue: {
webhook_url: '',
http_methods: ['POST'],
authorization: {
type: 'none',
},
'webhook_url': '',
'method': 'POST',
'content-type': 'application/json',
'headers': [],
'params': [],
'body': [],
'async_mode': true,
'status_code': 200,
'response_body': '',
'error_strategy': ErrorHandleTypeEnum.defaultValue,
'default_value': [] as DefaultValueForm[],
},
getAvailablePrevNodes(isChatMode: boolean) {
getAvailablePrevNodes(_isChatMode: boolean) {
return []
},
getAvailableNextNodes(isChatMode: boolean) {
@ -20,7 +28,7 @@ const nodeDefault: NodeDefault<WebhookTriggerNodeType> = {
: ALL_COMPLETION_AVAILABLE_BLOCKS.filter(type => type !== BlockEnum.End)
return nodes.filter(type => type !== BlockEnum.Start)
},
checkValid(payload: WebhookTriggerNodeType, t: any) {
checkValid(_payload: WebhookTriggerNodeType, _t: any) {
return {
isValid: true,
errorMessage: '',

View File

@ -1,24 +1,24 @@
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import type { WebhookTriggerNodeType } from './types'
import type { NodeProps } from '@/app/components/workflow/types'
const i18nPrefix = 'workflow.nodes.triggerWebhook'
const Node: FC<NodeProps<WebhookTriggerNodeType>> = ({
data,
}) => {
const { t } = useTranslation()
return (
<div className="mb-1 px-3 py-1">
<div className="text-xs text-gray-700">
{t(`${i18nPrefix}.nodeTitle`)}
<div className="mb-1 text-xs text-text-secondary">
URL
</div>
{data.http_methods && data.http_methods.length > 0 && (
<div className="text-xs text-gray-500">
{data.http_methods.join(', ')}
{data.webhook_url && (
<div
className="max-w-[200px] cursor-default truncate rounded bg-components-badge-bg-gray-soft px-2 py-1 text-xs text-text-tertiary"
title={data.webhook_url}
>
<span className="truncate" title={data.webhook_url}>
{data.webhook_url}
</span>
</div>
)}
</div>

View File

@ -1,24 +1,183 @@
import type { FC } from 'react'
import React from 'react'
import React, { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import type { WebhookTriggerNodeType } from './types'
import type { HttpMethod, WebhookParam, WebhookParameter, WebhookTriggerNodeType } from './types'
import useConfig from './use-config'
import ParameterTable from './components/parameter-table'
import HeaderTable from './components/header-table'
import Field from '@/app/components/workflow/nodes/_base/components/field'
import Split from '@/app/components/workflow/nodes/_base/components/split'
import type { NodePanelProps } from '@/app/components/workflow/types'
import InputWithCopy from '@/app/components/base/input-with-copy'
import Input from '@/app/components/base/input'
import Select from '@/app/components/base/select'
import Switch from '@/app/components/base/switch'
import Toast from '@/app/components/base/toast'
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: 'forms', value: 'forms' },
{ name: 'multipart/form-data', value: 'multipart/form-data' },
]
const Panel: FC<NodePanelProps<WebhookTriggerNodeType>> = ({
id,
data,
}) => {
const { t } = useTranslation()
const {
readOnly,
inputs,
handleMethodChange,
handleContentTypeChange,
handleHeadersChange,
handleParamsChange,
handleBodyChange,
handleAsyncModeChange,
handleStatusCodeChange,
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-2'>
<Field title={t(`${i18nPrefix}.title`)}>
<div className="text-sm text-gray-500">
{t(`${i18nPrefix}.configPlaceholder`)}
<div className='space-y-4 px-4 pb-3 pt-2'>
{/* Webhook URL Section */}
<Field title={t(`${i18nPrefix}.webhookUrl`)}>
<div className="space-y-2">
<div className="flex gap-2">
<div className="w-28 shrink-0">
<Select
items={HTTP_METHODS}
defaultValue={inputs.method}
onSelect={item => handleMethodChange(item.value as HttpMethod)}
disabled={readOnly}
allowSearch={false}
/>
</div>
<div className="flex flex-1 gap-2">
<InputWithCopy
value={inputs.webhook_url || ''}
placeholder={t(`${i18nPrefix}.webhookUrlPlaceholder`)}
readOnly
onCopy={() => {
Toast.notify({
type: 'success',
message: t(`${i18nPrefix}.urlCopied`),
})
}}
/>
</div>
</div>
</div>
</Field>
<Split />
{/* Content Type */}
<Field title={t(`${i18nPrefix}.contentType`)}>
<Select
items={CONTENT_TYPES}
defaultValue={inputs['content-type']}
onSelect={item => handleContentTypeChange(item.value as string)}
disabled={readOnly}
allowSearch={false}
/>
</Field>
<Split />
{/* Query Parameters */}
<ParameterTable
readonly={readOnly}
title="Query Parameters"
parameters={inputs.params as unknown as WebhookParam[]}
onChange={params => handleParamsChange(params as unknown as WebhookParameter[])}
placeholder={t(`${i18nPrefix}.noQueryParameters`)}
showType={false}
/>
<Split />
{/* Header Parameters */}
<HeaderTable
readonly={readOnly}
headers={inputs.headers}
onChange={handleHeadersChange}
/>
<Split />
{/* Request Body Parameters */}
<ParameterTable
readonly={readOnly}
title="Request Body Parameters"
parameters={inputs.body as unknown as WebhookParam[]}
onChange={params => handleBodyChange(params as unknown as WebhookParameter[])}
placeholder={t(`${i18nPrefix}.noBodyParameters`)}
showType={true}
isRequestBody={true}
/>
<Split />
{/* Response Configuration */}
<Field title={t(`${i18nPrefix}.responseConfiguration`)}>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="system-sm-medium text-text-secondary">
{t(`${i18nPrefix}.asyncMode`)}
</span>
<Switch
defaultValue={inputs.async_mode}
onChange={handleAsyncModeChange}
disabled={readOnly}
/>
</div>
<div>
<label className="system-sm-medium mb-2 block text-text-secondary">
{t(`${i18nPrefix}.statusCode`)}
</label>
<Input
type="number"
value={inputs.status_code}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => handleStatusCodeChange(Number(e.target.value))}
disabled={readOnly}
/>
</div>
<div>
<label className="system-sm-medium mb-2 block text-text-secondary">
{t(`${i18nPrefix}.responseBody`)}
</label>
<Input
value={inputs.response_body}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => handleResponseBodyChange(e.target.value)}
disabled={readOnly}
/>
</div>
</div>
</Field>
</div>
@ -26,4 +185,4 @@ const Panel: FC<NodePanelProps<WebhookTriggerNodeType>> = ({
)
}
export default React.memo(Panel)
export default Panel

View File

@ -1,10 +1,40 @@
import type { CommonNodeType } from '@/app/components/workflow/types'
import type { DefaultValueForm } from '@/app/components/workflow/nodes/_base/components/error-handle/types'
import type { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types'
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD'
export type ParameterType = 'string' | 'number' | 'boolean' | 'array' | 'object'
export type WebhookParameter = {
name: string
type: ParameterType
required: boolean
}
export type WebhookParam = {
key: string
type: string
value: string
required: boolean
}
export type WebhookHeader = {
name: string
required: boolean
}
export type WebhookTriggerNodeType = CommonNodeType & {
webhook_url?: string
http_methods?: string[]
authorization?: {
type: 'none' | 'bearer' | 'api_key'
config?: Record<string, any>
}
'webhook_url'?: string
'method': HttpMethod
'content-type': string
'headers': WebhookHeader[]
'params': WebhookParameter[]
'body': WebhookParameter[]
'async_mode': boolean
'status_code': number
'response_body': string
'http_methods'?: HttpMethod[]
'error_strategy'?: ErrorHandleTypeEnum
'default_value'?: DefaultValueForm[]
}

View File

@ -0,0 +1,137 @@
import { useCallback } from 'react'
import produce from 'immer'
import type { HttpMethod, WebhookHeader, WebhookParameter, WebhookTriggerNodeType } from './types'
import { useNodesReadOnly } 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 type { DefaultValueForm } from '@/app/components/workflow/nodes/_base/components/error-handle/types'
import type { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types'
import { fetchWebhookUrl } from '@/service/apps'
const useConfig = (id: string, payload: WebhookTriggerNodeType) => {
const { nodesReadOnly: readOnly } = useNodesReadOnly()
const { inputs, setInputs } = useNodeCrud<WebhookTriggerNodeType>(id, payload)
const appId = useAppStore.getState().appDetail?.id
const handleMethodChange = useCallback((method: HttpMethod) => {
setInputs(produce(inputs, (draft) => {
draft.method = method
}))
}, [inputs, setInputs])
const handleContentTypeChange = useCallback((contentType: string) => {
setInputs(produce(inputs, (draft) => {
draft['content-type'] = contentType
}))
}, [inputs, setInputs])
const handleHeadersChange = useCallback((headers: WebhookHeader[]) => {
setInputs(produce(inputs, (draft) => {
draft.headers = headers
}))
}, [inputs, setInputs])
const handleParamsChange = useCallback((params: WebhookParameter[]) => {
setInputs(produce(inputs, (draft) => {
draft.params = params
}))
}, [inputs, setInputs])
const handleBodyChange = useCallback((body: WebhookParameter[]) => {
setInputs(produce(inputs, (draft) => {
draft.body = body
}))
}, [inputs, setInputs])
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 handleResponseBodyChange = useCallback((responseBody: string) => {
setInputs(produce(inputs, (draft) => {
draft.response_body = responseBody
}))
}, [inputs, setInputs])
const handleErrorStrategyChange = useCallback((errorStrategy: ErrorHandleTypeEnum) => {
setInputs(produce(inputs, (draft) => {
draft.error_strategy = errorStrategy
}))
}, [inputs, setInputs])
const handleDefaultValueChange = useCallback((defaultValue: DefaultValueForm[]) => {
setInputs(produce(inputs, (draft) => {
draft.default_value = defaultValue
}))
}, [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 inputs.webhook_url
// Helper to build a deterministic mock URL for local/dev usage.
const buildMockUrl = () => `https://mock.dify.local/webhook/${appId ?? 'app'}/${id}`
if (!appId) {
// No appId available yet (e.g. during creation): use mock URL.
const mockUrl = buildMockUrl()
const newInputs = produce(inputs, (draft) => {
draft.webhook_url = mockUrl
})
setInputs(newInputs)
return mockUrl
}
try {
// Call backend to generate or fetch webhook url for this node
const response = await fetchWebhookUrl({ appId, nodeId: id })
const url = response.serverUrl
const newInputs = produce(inputs, (draft) => {
draft.webhook_url = url
})
setInputs(newInputs)
return url
}
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 mockUrl = buildMockUrl()
const newInputs = produce(inputs, (draft) => {
draft.webhook_url = mockUrl
})
setInputs(newInputs)
return mockUrl
}
}, [appId, id, inputs, setInputs])
return {
readOnly,
inputs,
setInputs,
handleMethodChange,
handleContentTypeChange,
handleHeadersChange,
handleParamsChange,
handleBodyChange,
handleAsyncModeChange,
handleStatusCodeChange,
handleResponseBodyChange,
handleErrorStrategyChange,
handleDefaultValueChange,
generateWebhookUrl,
}
}
export default useConfig

View File

@ -350,5 +350,5 @@ export const getParallelInfo = (nodes: Node[], edges: Edge[], parentNodeId?: str
}
export const hasErrorHandleNode = (nodeType?: BlockEnum) => {
return nodeType === BlockEnum.LLM || nodeType === BlockEnum.Tool || nodeType === BlockEnum.HttpRequest || nodeType === BlockEnum.Code
return nodeType === BlockEnum.LLM || nodeType === BlockEnum.Tool || nodeType === BlockEnum.HttpRequest || nodeType === BlockEnum.Code || nodeType === BlockEnum.TriggerWebhook
}

View File

@ -979,6 +979,36 @@ const translation = {
title: 'Webhook Trigger',
nodeTitle: '🔗 Webhook Trigger',
configPlaceholder: 'Webhook trigger configuration will be implemented here',
webhookUrl: 'Webhook URL',
webhookUrlPlaceholder: 'Click generate to create webhook URL',
generate: 'Generate',
copy: 'Copy',
test: 'Test',
urlGenerated: 'Webhook URL generated successfully',
urlGenerationFailed: 'Failed to generate webhook URL',
urlCopied: 'URL copied to clipboard',
method: 'Method',
contentType: 'Content Type',
queryParameters: 'Query Parameters',
headerParameters: 'Header Parameters',
requestBodyParameters: 'Request Body Parameters',
parameterName: 'Variable name',
headerName: 'Variable name',
required: 'Required',
addParameter: 'Add',
addHeader: 'Add',
noParameters: 'No parameters configured',
noQueryParameters: 'No query parameters configured',
noHeaders: 'No headers configured',
noBodyParameters: 'No body parameters configured',
errorHandling: 'Error Handling',
errorStrategy: 'Error Handling',
responseConfiguration: 'Response Configuration',
asyncMode: 'Async Mode',
statusCode: 'Status Code',
responseBody: 'Response Body',
responseBodyPlaceholder: 'Response body content',
headers: 'Headers',
},
},
triggerStatus: {

View File

@ -977,6 +977,36 @@ const translation = {
title: 'Webhook トリガー',
nodeTitle: '🔗 Webhook トリガー',
configPlaceholder: 'Webhook トリガーの設定がここに実装されます',
webhookUrl: 'Webhook URL',
webhookUrlPlaceholder: '生成をクリックして Webhook URL を作成',
generate: '生成',
copy: 'コピー',
test: 'テスト',
urlGenerated: 'Webhook URL を生成しました',
urlGenerationFailed: 'Webhook URL の生成に失敗しました',
urlCopied: 'URL をクリップボードにコピーしました',
method: 'メソッド',
contentType: 'コンテンツタイプ',
queryParameters: 'クエリパラメータ',
headerParameters: 'ヘッダーパラメータ',
requestBodyParameters: 'リクエストボディパラメータ',
parameterName: '変数名',
headerName: '変数名',
required: '必須',
addParameter: '追加',
addHeader: '追加',
noParameters: '設定されたパラメータはありません',
noQueryParameters: 'クエリパラメータは設定されていません',
noHeaders: 'ヘッダーは設定されていません',
noBodyParameters: 'ボディパラメータは設定されていません',
errorHandling: 'エラー処理',
errorStrategy: 'エラー処理',
responseConfiguration: 'レスポンス設定',
asyncMode: '非同期モード',
statusCode: 'ステータスコード',
responseBody: 'レスポンスボディ',
responseBodyPlaceholder: 'レスポンス本文',
headers: 'ヘッダー',
},
},
tracing: {

View File

@ -977,6 +977,36 @@ const translation = {
configPlaceholder: 'Webhook 触发器配置将在此处实现',
title: 'Webhook 触发器',
nodeTitle: '🔗 Webhook 触发器',
webhookUrl: 'Webhook URL',
webhookUrlPlaceholder: '点击生成以创建 webhook URL',
generate: '生成',
copy: '复制',
test: '测试',
urlGenerated: 'Webhook URL 生成成功',
urlGenerationFailed: '生成 Webhook URL 失败',
urlCopied: 'URL 已复制到剪贴板',
method: '方法',
contentType: '内容类型',
queryParameters: '查询参数',
headerParameters: 'Header 参数',
requestBodyParameters: '请求体参数',
parameterName: '变量名',
headerName: '变量名',
required: '必填',
addParameter: '添加',
addHeader: '添加',
noParameters: '未配置任何参数',
noQueryParameters: '未配置查询参数',
noHeaders: '未配置 Header',
noBodyParameters: '未配置请求体参数',
errorHandling: '错误处理',
errorStrategy: '错误处理',
responseConfiguration: '响应配置',
asyncMode: '异步模式',
statusCode: '状态码',
responseBody: '响应体',
responseBodyPlaceholder: '响应体内容',
headers: 'Headers',
},
},
tracing: {

View File

@ -157,6 +157,11 @@ export const updateTracingStatus: Fetcher<CommonResponse, { appId: string; body:
return post(`/apps/${appId}/trace`, { body })
}
// Webhook Trigger
export const fetchWebhookUrl: Fetcher<{ serverUrl: string }, { appId: string; nodeId: string }> = ({ appId, nodeId }) => {
return get<{ serverUrl: string }>(`apps/${appId}/webhook-url`, { params: { node: nodeId } })
}
export const fetchTracingConfig: Fetcher<TracingConfig & { has_not_configured: true }, { appId: string; provider: TracingProvider }> = ({ appId, provider }) => {
return get(`/apps/${appId}/trace-config`, {
params: {