fix: enhance webhook trigger panel UI consistency and user experience (#24780)

This commit is contained in:
lyzno1
2025-08-29 17:41:42 +08:00
committed by GitHub
parent d8ddbc4d87
commit 60b5ed8e5d
9 changed files with 213 additions and 86 deletions

View File

@@ -0,0 +1,86 @@
/**
* Webhook Trigger Node Default Tests
*
* Tests for webhook trigger node default configuration and field validation.
* Tests core checkValid functionality following project patterns.
*/
import nodeDefault from '../default'
// Simple mock translation function
const mockT = (key: string, params?: any) => {
if (key.includes('fieldRequired')) return `${params?.field} is required`
return key
}
describe('Webhook Trigger Node Default', () => {
describe('Basic Configuration', () => {
it('should have correct default values for all backend fields', () => {
const defaultValue = nodeDefault.defaultValue
// Core webhook configuration
expect(defaultValue.webhook_url).toBe('')
expect(defaultValue.method).toBe('POST')
expect(defaultValue['content-type']).toBe('application/json')
// Response configuration fields
expect(defaultValue.async_mode).toBe(true)
expect(defaultValue.status_code).toBe(200)
expect(defaultValue.response_body).toBe('')
// Parameter arrays
expect(Array.isArray(defaultValue.headers)).toBe(true)
expect(Array.isArray(defaultValue.params)).toBe(true)
expect(Array.isArray(defaultValue.body)).toBe(true)
expect(Array.isArray(defaultValue.default_value)).toBe(true)
// Initial arrays should be empty
expect(defaultValue.headers).toHaveLength(0)
expect(defaultValue.params).toHaveLength(0)
expect(defaultValue.body).toHaveLength(0)
expect(defaultValue.default_value).toHaveLength(0)
})
it('should have empty prev nodes', () => {
const prevNodes = nodeDefault.getAvailablePrevNodes(false)
expect(prevNodes).toEqual([])
})
it('should have available next nodes excluding Start', () => {
const nextNodes = nodeDefault.getAvailableNextNodes(false)
expect(nextNodes).toBeDefined()
expect(nextNodes.length).toBeGreaterThan(0)
})
})
describe('Validation - checkValid', () => {
it('should validate successfully with default configuration', () => {
const payload = nodeDefault.defaultValue
const result = nodeDefault.checkValid(payload, mockT)
expect(result.isValid).toBe(true)
expect(result.errorMessage).toBe('')
})
it('should handle response configuration fields', () => {
const payload = {
...nodeDefault.defaultValue,
status_code: 404,
response_body: '{"error": "Not found"}',
}
const result = nodeDefault.checkValid(payload, mockT)
expect(result.isValid).toBe(true)
})
it('should handle async_mode field correctly', () => {
const payload = {
...nodeDefault.defaultValue,
async_mode: false,
}
const result = nodeDefault.checkValid(payload, mockT)
expect(result.isValid).toBe(true)
})
})
})

View File

@@ -5,6 +5,7 @@ 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'
// Column configuration types for table components
@@ -60,9 +61,6 @@ const GenericTable: FC<GenericTableProps> = ({
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
@@ -131,7 +129,18 @@ const GenericTable: FC<GenericTableProps> = ({
return (
<Input
value={(value as string) || ''}
onChange={e => handleChange(e.target.value)}
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"
@@ -139,7 +148,7 @@ const GenericTable: FC<GenericTableProps> = ({
// 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',
'system-sm-regular text-text-secondary placeholder:text-text-quaternary',
)}
/>
)
@@ -158,20 +167,21 @@ const GenericTable: FC<GenericTableProps> = ({
'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"
optionWrapClassName="w-26 min-w-26 z-[5] -ml-3"
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"
/>
<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':
@@ -184,73 +194,64 @@ const GenericTable: FC<GenericTableProps> = ({
const renderTable = () => {
return (
<div className="rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg shadow-xs">
<div className="rounded-lg border border-divider-regular">
{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 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(
'text-xs uppercase text-text-tertiary',
column.width && column.width.startsWith('w-') ? 'shrink-0' : 'min-w-0 flex-1 overflow-hidden',
'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',
)}
>
<span className="truncate">{column.title}</span>
{column.title}
</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}`
// Check if primary identifier column has content
const primaryColumn = columns.find(col => col.key === 'key' || col.key === 'name')?.key || 'key'
const hasContent = row[primaryColumn] && String(row[primaryColumn]).trim() !== ''
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,
'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 => (
{columns.map((column, columnIndex) => (
<div
key={column.key}
className={cn(
'relative',
column.width && column.width.startsWith('w-') ? 'shrink-0' : 'min-w-0 flex-1',
'shrink-0 pl-3',
column.width,
// Avoid children overflow when content is long in flexible columns
!(column.width && column.width.startsWith('w-')) && 'overflow-hidden',
// Add right border except for last column
columnIndex < columns.length - 1 && 'border-r border-divider-regular',
)}
>
{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,
)}
>
{!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={() => dataIndex !== null && removeRow(dataIndex)}
className="text-text-tertiary opacity-70 transition-colors hover:text-text-destructive hover:opacity-100"
onClick={() => removeRow(dataIndex)}
className="p-1"
aria-label="Delete row"
>
<RiDeleteBinLine className="h-3.5 w-3.5" />
<RiDeleteBinLine className="h-3.5 w-3.5 text-text-destructive" />
</button>
</div>
)}
@@ -272,7 +273,7 @@ const GenericTable: FC<GenericTableProps> = ({
</div>
{showPlaceholder ? (
<div className="py-8 text-center text-sm text-text-tertiary">
<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>
) : (

View File

@@ -32,7 +32,7 @@ const HeaderTable: FC<HeaderTableProps> = ({
key: 'required',
title: 'Required',
type: 'switch',
width: 'w-[48px]',
width: 'w-[88px]',
},
]
@@ -53,9 +53,9 @@ const HeaderTable: FC<HeaderTableProps> = ({
// Handle data changes
const handleDataChange = (data: GenericTableRow[]) => {
const newHeaders: WebhookHeader[] = data
.filter(row => row.name && row.name.trim() !== '')
.filter(row => row.name && typeof row.name === 'string' && row.name.trim() !== '')
.map(row => ({
name: row.name || '',
name: (row.name as string) || '',
required: !!row.required,
}))
onChange(newHeaders)

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

@@ -63,7 +63,7 @@ const ParameterTable: FC<ParameterTableProps> = ({
key: 'type',
title: 'Type',
type: (isRequestBody ? 'select' : 'input') as ColumnConfig['type'],
width: 'w-[96px]',
width: 'w-[78px]',
placeholder: 'Type',
options: isRequestBody ? typeOptions : undefined,
}]
@@ -72,14 +72,14 @@ const ParameterTable: FC<ParameterTableProps> = ({
key: 'required',
title: 'Required',
type: 'switch',
width: 'w-[48px]',
width: 'w-[88px]',
},
]
// Empty row template for new rows
const emptyRowData: GenericTableRow = {
key: '',
type: '',
type: isRequestBody ? 'string' : '',
required: false,
}

View File

@@ -6,13 +6,13 @@ 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 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 { SimpleSelect } from '@/app/components/base/select'
import Switch from '@/app/components/base/switch'
import Toast from '@/app/components/base/toast'
import Tooltip from '@/app/components/base/tooltip'
import copy from 'copy-to-clipboard'
@@ -50,7 +50,6 @@ const Panel: FC<NodePanelProps<WebhookTriggerNodeType>> = ({
handleHeadersChange,
handleParamsChange,
handleBodyChange,
handleAsyncModeChange,
handleStatusCodeChange,
handleResponseBodyChange,
generateWebhookUrl,
@@ -71,7 +70,7 @@ const Panel: FC<NodePanelProps<WebhookTriggerNodeType>> = ({
{/* Webhook URL Section */}
<Field title={t(`${i18nPrefix}.webhookUrl`)}>
<div className="space-y-1">
<div className="flex gap-1" style={{ width: '368px', height: '32px' }}>
<div className="flex gap-1" style={{ height: '32px' }}>
<div className="w-26 shrink-0">
<SimpleSelect
items={HTTP_METHODS}
@@ -105,7 +104,7 @@ const Panel: FC<NodePanelProps<WebhookTriggerNodeType>> = ({
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={false}
needsDelay={true}
>
<div
className="flex cursor-pointer gap-1.5 rounded-lg px-1 py-1.5 transition-colors"
@@ -130,9 +129,6 @@ const Panel: FC<NodePanelProps<WebhookTriggerNodeType>> = ({
)}
</div>
</Field>
<span>{inputs.webhook_debug_url || ''}</span>
<Split />
{/* Content Type */}
<Field title={t(`${i18nPrefix}.contentType`)}>
@@ -151,8 +147,6 @@ const Panel: FC<NodePanelProps<WebhookTriggerNodeType>> = ({
</div>
</Field>
<Split />
{/* Query Parameters */}
<ParameterTable
readonly={readOnly}
@@ -163,8 +157,6 @@ const Panel: FC<NodePanelProps<WebhookTriggerNodeType>> = ({
showType={false}
/>
<Split />
{/* Header Parameters */}
<HeaderTable
readonly={readOnly}
@@ -172,8 +164,6 @@ const Panel: FC<NodePanelProps<WebhookTriggerNodeType>> = ({
onChange={handleHeadersChange}
/>
<Split />
{/* Request Body Parameters */}
<ParameterTable
readonly={readOnly}
@@ -191,17 +181,7 @@ const Panel: FC<NodePanelProps<WebhookTriggerNodeType>> = ({
<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">
<label className="system-sm-medium text-text-tertiary">
{t(`${i18nPrefix}.statusCode`)}
</label>
<Input
@@ -209,15 +189,18 @@ const Panel: FC<NodePanelProps<WebhookTriggerNodeType>> = ({
value={inputs.status_code}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => handleStatusCodeChange(Number(e.target.value))}
disabled={readOnly}
wrapperClassName="w-[120px]"
className="h-8"
/>
</div>
<div>
<label className="system-sm-medium mb-2 block text-text-secondary">
<label className="system-sm-medium mb-2 block text-text-tertiary">
{t(`${i18nPrefix}.responseBody`)}
</label>
<Input
<ParagraphInput
value={inputs.response_body}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => handleResponseBodyChange(e.target.value)}
onChange={handleResponseBodyChange}
placeholder={t(`${i18nPrefix}.responseBodyPlaceholder`)}
disabled={readOnly}
/>
</div>

View File

@@ -1022,11 +1022,11 @@ const translation = {
debugUrlCopied: 'Copied!',
errorHandling: 'Error Handling',
errorStrategy: 'Error Handling',
responseConfiguration: 'Response Configuration',
responseConfiguration: 'Response',
asyncMode: 'Async Mode',
statusCode: 'Status Code',
responseBody: 'Response Body',
responseBodyPlaceholder: 'Response body content',
responseBodyPlaceholder: 'Write your response body here',
headers: 'Headers',
},
},

View File

@@ -1022,11 +1022,11 @@ const translation = {
debugUrlCopied: 'コピーしました!',
errorHandling: 'エラー処理',
errorStrategy: 'エラー処理',
responseConfiguration: 'レスポンス設定',
responseConfiguration: 'レスポンス',
asyncMode: '非同期モード',
statusCode: 'ステータスコード',
responseBody: 'レスポンスボディ',
responseBodyPlaceholder: 'レスポンス本文',
responseBodyPlaceholder: 'ここにレスポンスボディを入力してください',
headers: 'ヘッダー',
},
},

View File

@@ -1022,11 +1022,11 @@ const translation = {
debugUrlCopied: '已复制!',
errorHandling: '错误处理',
errorStrategy: '错误处理',
responseConfiguration: '响应配置',
responseConfiguration: '响应',
asyncMode: '异步模式',
statusCode: '状态码',
responseBody: '响应体',
responseBodyPlaceholder: '响应体内容',
responseBodyPlaceholder: '在此输入您的响应体',
headers: 'Headers',
},
},