dify/web/app/components/workflow/nodes/trigger-webhook/panel.tsx
Stephen Zhou a84c2d36a3
style: format with vp fmt (#38803)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-12 15:57:46 +00:00

320 lines
12 KiB
TypeScript

import type { FC } from 'react'
import type { HttpMethod, WebhookTriggerNodeType } from './types'
import type { NodePanelProps } from '@/app/components/workflow/types'
import {
NumberField,
NumberFieldControls,
NumberFieldDecrement,
NumberFieldGroup,
NumberFieldIncrement,
NumberFieldInput,
} from '@langgenius/dify-ui/number-field'
import {
Select,
SelectContent,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectLabel,
SelectTrigger,
} from '@langgenius/dify-ui/select'
import { toast } from '@langgenius/dify-ui/toast'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import copy from 'copy-to-clipboard'
import * as React from 'react'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import InputWithCopy from '@/app/components/base/input-with-copy'
import Field from '@/app/components/workflow/nodes/_base/components/field'
import OutputVars from '@/app/components/workflow/nodes/_base/components/output-vars'
import Split from '@/app/components/workflow/nodes/_base/components/split'
import { isPrivateOrLocalAddress } from '@/utils/urlValidation'
import HeaderTable from './components/header-table'
import ParagraphInput from './components/paragraph-input'
import ParameterTable from './components/parameter-table'
import { DEFAULT_STATUS_CODE, MAX_STATUS_CODE, useConfig } from './use-config'
import { OutputVariablesContent } from './utils/render-output-vars'
const i18nPrefix = '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' },
] satisfies Array<{ name: string; value: HttpMethod }>
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: 'application/octet-stream', value: 'application/octet-stream' },
{ name: 'multipart/form-data', value: 'multipart/form-data' },
]
type WebhookMethodSelectorProps = {
nodeId: string
label: string
value: HttpMethod
disabled: boolean
onChange: (method: HttpMethod) => void
}
const WebhookMethodSelector = ({
nodeId,
label,
value,
disabled,
onChange,
}: WebhookMethodSelectorProps) => {
const selectedMethod = HTTP_METHODS.find((item) => item.value === value) ?? null
const handleMethodChange = (nextValue: string | null) => {
const nextMethod = HTTP_METHODS.find((item) => item.value === nextValue)
if (nextMethod) onChange(nextMethod.value)
}
return (
<Select
key={`${nodeId}-method-${value}`}
value={selectedMethod?.value ?? null}
disabled={disabled}
onValueChange={handleMethodChange}
>
<SelectTrigger aria-label={label} className="h-8 pr-8 text-sm">
{selectedMethod?.name}
</SelectTrigger>
<SelectContent popupClassName="w-26 min-w-26">
{HTTP_METHODS.map((item) => (
<SelectItem key={item.value} value={item.value}>
<SelectItemText>{item.name}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
)
}
const Panel: FC<NodePanelProps<WebhookTriggerNodeType>> = ({ id, data }) => {
const { t } = useTranslation()
const [debugUrlCopied, setDebugUrlCopied] = React.useState(false)
const [outputVarsCollapsed, setOutputVarsCollapsed] = useState(false)
const {
readOnly,
inputs,
handleMethodChange,
handleContentTypeChange,
handleHeadersChange,
handleParamsChange,
handleBodyChange,
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])
const selectedContentType =
CONTENT_TYPES.find((item) => item.value === inputs.content_type) ?? null
return (
<div className="mt-2">
<div className="space-y-4 px-4 pt-2 pb-3">
{/* Webhook URL Section */}
<Field title={t(($) => $[`${i18nPrefix}.webhookUrl`], { ns: 'workflow' })}>
<div className="space-y-1">
<div className="flex gap-1" style={{ height: '32px' }}>
<div className="w-26 shrink-0">
<WebhookMethodSelector
nodeId={id}
label={t(($) => $[`${i18nPrefix}.method`], { ns: 'workflow' })}
value={inputs.method}
disabled={readOnly}
onChange={handleMethodChange}
/>
</div>
<div className="flex-1" style={{ width: '284px' }}>
<InputWithCopy
value={inputs.webhook_url || ''}
placeholder={t(($) => $[`${i18nPrefix}.webhookUrlPlaceholder`], {
ns: 'workflow',
})}
readOnly
onCopy={() => {
toast.success(t(($) => $[`${i18nPrefix}.urlCopied`], { ns: 'workflow' }))
}}
/>
</div>
</div>
{inputs.webhook_debug_url && (
<div className="space-y-2">
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
aria-label={t(($) => $[`${i18nPrefix}.debugUrlCopy`], { ns: 'workflow' })}
className="flex cursor-pointer gap-1.5 rounded-lg px-1 py-1.5 text-left transition-colors"
style={{ width: '368px', height: '38px' }}
onClick={() => {
copy(inputs.webhook_debug_url || '')
setDebugUrlCopied(true)
setTimeout(() => setDebugUrlCopied(false), 2000)
}}
>
<span
className="mt-0.5 w-0.5 bg-divider-regular"
style={{ height: '28px' }}
/>
<span className="flex-1" style={{ width: '352px', height: '32px' }}>
<span className="block text-xs/4 text-text-tertiary">
{t(($) => $[`${i18nPrefix}.debugUrlTitle`], { ns: 'workflow' })}
</span>
<span className="block truncate text-xs/4 text-text-primary">
{inputs.webhook_debug_url}
</span>
</span>
</button>
}
/>
<TooltipContent
placement="top"
className="rounded-md border border-components-panel-border bg-components-tooltip-bg px-1.5 py-1 system-xs-regular text-text-primary shadow-lg backdrop-blur-xs"
>
{debugUrlCopied
? t(($) => $[`${i18nPrefix}.debugUrlCopied`], { ns: 'workflow' })
: t(($) => $[`${i18nPrefix}.debugUrlCopy`], { ns: 'workflow' })}
</TooltipContent>
</Tooltip>
{isPrivateOrLocalAddress(inputs.webhook_debug_url) && (
<div className="mt-1 px-0 py-[2px] system-xs-regular text-text-warning">
{t(($) => $[`${i18nPrefix}.debugUrlPrivateAddressWarning`], { ns: 'workflow' })}
</div>
)}
</div>
)}
</div>
</Field>
{/* Content Type */}
<Field title={t(($) => $[`${i18nPrefix}.contentType`], { ns: 'workflow' })}>
<div className="w-full max-w-[392px]">
<Select
key={`${id}-content-type-${inputs.content_type}`}
value={selectedContentType?.value ?? null}
disabled={readOnly}
onValueChange={(value) => value && handleContentTypeChange(value)}
>
<SelectLabel className="sr-only">
{t(($) => $[`${i18nPrefix}.contentType`], { ns: 'workflow' })}
</SelectLabel>
<SelectTrigger className="h-8 w-full text-sm">
{selectedContentType?.name}
</SelectTrigger>
<SelectContent>
{CONTENT_TYPES.map((item) => (
<SelectItem key={item.value} value={item.value}>
<SelectItemText>{item.name}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</Field>
{/* Query Parameters */}
<ParameterTable
readonly={readOnly}
title="Query Parameters"
parameters={inputs.params}
onChange={handleParamsChange}
placeholder={t(($) => $[`${i18nPrefix}.noQueryParameters`], { ns: 'workflow' })}
/>
{/* Header Parameters */}
<HeaderTable readonly={readOnly} headers={inputs.headers} onChange={handleHeadersChange} />
{/* Request Body Parameters */}
<ParameterTable
readonly={readOnly}
title="Request Body Parameters"
parameters={inputs.body}
onChange={handleBodyChange}
placeholder={t(($) => $[`${i18nPrefix}.noBodyParameters`], { ns: 'workflow' })}
contentType={inputs.content_type}
/>
<Split />
{/* Response Configuration */}
<Field title={t(($) => $[`${i18nPrefix}.responseConfiguration`], { ns: 'workflow' })}>
<div className="space-y-3">
<div className="flex items-center justify-between">
<label className="system-sm-medium text-text-tertiary">
{t(($) => $[`${i18nPrefix}.statusCode`], { ns: 'workflow' })}
</label>
<NumberField
className="w-[120px]"
min={DEFAULT_STATUS_CODE}
max={MAX_STATUS_CODE}
value={inputs.status_code ?? DEFAULT_STATUS_CODE}
disabled={readOnly}
onValueChange={(value) => value !== null && handleStatusCodeChange(value)}
onValueCommitted={(value, eventDetails) => {
if (eventDetails.reason === 'input-clear')
handleStatusCodeChange(value ?? DEFAULT_STATUS_CODE)
}}
>
<NumberFieldGroup>
<NumberFieldInput
aria-label={t(($) => $[`${i18nPrefix}.statusCode`], { ns: 'workflow' })}
className="h-8"
/>
<NumberFieldControls>
<NumberFieldIncrement />
<NumberFieldDecrement />
</NumberFieldControls>
</NumberFieldGroup>
</NumberField>
</div>
<div>
<label className="mb-2 block system-sm-medium text-text-tertiary">
{t(($) => $[`${i18nPrefix}.responseBody`], { ns: 'workflow' })}
</label>
<ParagraphInput
value={inputs.response_body}
onChange={handleResponseBodyChange}
placeholder={t(($) => $[`${i18nPrefix}.responseBodyPlaceholder`], {
ns: 'workflow',
})}
disabled={readOnly}
/>
</div>
</div>
</Field>
</div>
<Split />
<div className="">
<OutputVars collapsed={outputVarsCollapsed} onCollapse={setOutputVarsCollapsed}>
<OutputVariablesContent variables={inputs.variables} />
</OutputVars>
</div>
</div>
)
}
export default Panel