mirror of https://github.com/langgenius/dify.git
feat: webhook trigger frontend (#24311)
This commit is contained in:
parent
a7b558b38b
commit
6b0d919dbd
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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: '',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Reference in New Issue