diff --git a/web/app/components/base/input-with-copy/index.spec.tsx b/web/app/components/base/input-with-copy/index.spec.tsx new file mode 100644 index 0000000000..f302f1715a --- /dev/null +++ b/web/app/components/base/input-with-copy/index.spec.tsx @@ -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 = { + '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() + 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() + 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() + + 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() + + 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() + + 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() + + 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( + , + ) + + 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() + 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() + + 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() + }) +}) diff --git a/web/app/components/base/input-with-copy/index.tsx b/web/app/components/base/input-with-copy/index.tsx new file mode 100644 index 0000000000..0d10714b86 --- /dev/null +++ b/web/app/components/base/input-with-copy/index.tsx @@ -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 // Remove conflicting props + +const prefixEmbedded = 'appOverview.overview.appInfo.embedded' + +const InputWithCopy = React.forwardRef(( + { + showCopyButton = true, + copyValue, + onCopy, + value, + wrapperClassName, + ...inputProps + }, + ref, +) => { + const { t } = useTranslation() + const [isCopied, setIsCopied] = useState(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 ( +
+ rest)(inputProps)} + /> + {showCopyButton && ( +
+ + + {isCopied ? ( + + ) : ( + + )} + + +
+ )} +
+ ) +}) + +InputWithCopy.displayName = 'InputWithCopy' + +export default InputWithCopy diff --git a/web/app/components/workflow/nodes/trigger-webhook/components/generic-table.tsx b/web/app/components/workflow/nodes/trigger-webhook/components/generic-table.tsx new file mode 100644 index 0000000000..9bad9036bb --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-webhook/components/generic-table.tsx @@ -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 = ({ + 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(() => { + // 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 ( + 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 ( + 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 ( + 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 ( +
+ {showHeader && ( +
+ {columns.map(column => ( +
+ {column.title} +
+ ))} +
+ )} +
+ {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 ( +
+ {columns.map(column => ( +
+ {renderCell(column, row, dataIndex)} +
+ ))} + {!readonly && data.length > 1 && !isEmpty && !isVirtual && ( + + )} +
+ ) + })} +
+
+ ) + } + + // Show placeholder only when readonly and there is no data configured + const showPlaceholder = readonly && data.length === 0 + + return ( +
+
+

{title}

+
+ + {showPlaceholder ? ( +
+ {placeholder} +
+ ) : ( + renderTable() + )} +
+ ) +} + +export default React.memo(GenericTable) diff --git a/web/app/components/workflow/nodes/trigger-webhook/components/header-table.tsx b/web/app/components/workflow/nodes/trigger-webhook/components/header-table.tsx new file mode 100644 index 0000000000..8d00a33b9c --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-webhook/components/header-table.tsx @@ -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 = ({ + 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 ( + + ) +} + +export default React.memo(HeaderTable) diff --git a/web/app/components/workflow/nodes/trigger-webhook/components/parameter-table.tsx b/web/app/components/workflow/nodes/trigger-webhook/components/parameter-table.tsx new file mode 100644 index 0000000000..5ad49662d6 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-webhook/components/parameter-table.tsx @@ -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 = ({ + 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 ( + + ) +} + +export default ParameterTable diff --git a/web/app/components/workflow/nodes/trigger-webhook/default.ts b/web/app/components/workflow/nodes/trigger-webhook/default.ts index 104f9fb0b2..9bb225097d 100644 --- a/web/app/components/workflow/nodes/trigger-webhook/default.ts +++ b/web/app/components/workflow/nodes/trigger-webhook/default.ts @@ -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 = { 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 = { : 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: '', diff --git a/web/app/components/workflow/nodes/trigger-webhook/node.tsx b/web/app/components/workflow/nodes/trigger-webhook/node.tsx index bb6fd48283..69da127161 100644 --- a/web/app/components/workflow/nodes/trigger-webhook/node.tsx +++ b/web/app/components/workflow/nodes/trigger-webhook/node.tsx @@ -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> = ({ data, }) => { - const { t } = useTranslation() - return (
-
- {t(`${i18nPrefix}.nodeTitle`)} +
+ URL
- {data.http_methods && data.http_methods.length > 0 && ( -
- {data.http_methods.join(', ')} + {data.webhook_url && ( +
+ + {data.webhook_url} +
)}
diff --git a/web/app/components/workflow/nodes/trigger-webhook/panel.tsx b/web/app/components/workflow/nodes/trigger-webhook/panel.tsx index ff2ef5cec9..2191e43d6b 100644 --- a/web/app/components/workflow/nodes/trigger-webhook/panel.tsx +++ b/web/app/components/workflow/nodes/trigger-webhook/panel.tsx @@ -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> = ({ 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 (
-
- -
- {t(`${i18nPrefix}.configPlaceholder`)} +
+ {/* Webhook URL Section */} + +
+
+
+ handleContentTypeChange(item.value as string)} + disabled={readOnly} + allowSearch={false} + /> + + + + + {/* Query Parameters */} + handleParamsChange(params as unknown as WebhookParameter[])} + placeholder={t(`${i18nPrefix}.noQueryParameters`)} + showType={false} + /> + + + + {/* Header Parameters */} + + + + + {/* Request Body Parameters */} + handleBodyChange(params as unknown as WebhookParameter[])} + placeholder={t(`${i18nPrefix}.noBodyParameters`)} + showType={true} + isRequestBody={true} + /> + + + + {/* Response Configuration */} + +
+
+ + {t(`${i18nPrefix}.asyncMode`)} + + +
+
+ + ) => handleStatusCodeChange(Number(e.target.value))} + disabled={readOnly} + /> +
+
+ + ) => handleResponseBodyChange(e.target.value)} + disabled={readOnly} + /> +
@@ -26,4 +185,4 @@ const Panel: FC> = ({ ) } -export default React.memo(Panel) +export default Panel diff --git a/web/app/components/workflow/nodes/trigger-webhook/types.ts b/web/app/components/workflow/nodes/trigger-webhook/types.ts index 8a118e7086..cbc6ae2b24 100644 --- a/web/app/components/workflow/nodes/trigger-webhook/types.ts +++ b/web/app/components/workflow/nodes/trigger-webhook/types.ts @@ -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 - } + '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[] } diff --git a/web/app/components/workflow/nodes/trigger-webhook/use-config.ts b/web/app/components/workflow/nodes/trigger-webhook/use-config.ts new file mode 100644 index 0000000000..3c1fb1347f --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-webhook/use-config.ts @@ -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(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 diff --git a/web/app/components/workflow/utils/workflow.ts b/web/app/components/workflow/utils/workflow.ts index 5f95013673..a93ce7ce12 100644 --- a/web/app/components/workflow/utils/workflow.ts +++ b/web/app/components/workflow/utils/workflow.ts @@ -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 } diff --git a/web/i18n/en-US/workflow.ts b/web/i18n/en-US/workflow.ts index 54b0f322a5..bad6734a24 100644 --- a/web/i18n/en-US/workflow.ts +++ b/web/i18n/en-US/workflow.ts @@ -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: { diff --git a/web/i18n/ja-JP/workflow.ts b/web/i18n/ja-JP/workflow.ts index 58ffb9d746..731b7d1bf4 100644 --- a/web/i18n/ja-JP/workflow.ts +++ b/web/i18n/ja-JP/workflow.ts @@ -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: { diff --git a/web/i18n/zh-Hans/workflow.ts b/web/i18n/zh-Hans/workflow.ts index 2c92eb4d22..9897cd5688 100644 --- a/web/i18n/zh-Hans/workflow.ts +++ b/web/i18n/zh-Hans/workflow.ts @@ -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: { diff --git a/web/service/apps.ts b/web/service/apps.ts index 1d7b0bccdb..9f1e00b497 100644 --- a/web/service/apps.ts +++ b/web/service/apps.ts @@ -157,6 +157,11 @@ export const updateTracingStatus: Fetcher = ({ appId, nodeId }) => { + return get<{ serverUrl: string }>(`apps/${appId}/webhook-url`, { params: { node: nodeId } }) +} + export const fetchTracingConfig: Fetcher = ({ appId, provider }) => { return get(`/apps/${appId}/trace-config`, { params: {