feat: introduce trigger functionality (#27644)
Signed-off-by: lyzno1 <yuanyouhuilyz@gmail.com> Co-authored-by: Stream <Stream_2@qq.com> Co-authored-by: lyzno1 <92089059+lyzno1@users.noreply.github.com> Co-authored-by: zhsama <torvalds@linux.do> Co-authored-by: Harry <xh001x@hotmail.com> Co-authored-by: lyzno1 <yuanyouhuilyz@gmail.com> Co-authored-by: yessenia <yessenia.contact@gmail.com> Co-authored-by: hjlarry <hjlarry@163.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: WTW0313 <twwu@dify.ai> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user