mirror of
https://github.com/langgenius/dify.git
synced 2026-04-30 15:38:08 +08:00
Merge main HEAD (segment 5) into sandboxed-agent-rebase
Resolve 83 conflicts: 10 backend, 62 frontend, 11 config/lock files. Preserve sandbox/agent/collaboration features while adopting main's UI refactorings (Dialog/AlertDialog/Popover), model provider updates, and enterprise features. Made-with: Cursor
This commit is contained in:
@ -0,0 +1,47 @@
|
||||
import type { WebhookTriggerNodeType } from '../types'
|
||||
import { screen } from '@testing-library/react'
|
||||
import { renderNodeComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import Node from '../node'
|
||||
|
||||
const createNodeData = (overrides: Partial<WebhookTriggerNodeType> = {}): WebhookTriggerNodeType => ({
|
||||
title: 'Webhook Trigger',
|
||||
desc: '',
|
||||
type: BlockEnum.TriggerWebhook,
|
||||
method: 'POST',
|
||||
content_type: 'application/json',
|
||||
headers: [],
|
||||
params: [],
|
||||
body: [],
|
||||
async_mode: false,
|
||||
status_code: 200,
|
||||
response_body: '',
|
||||
variables: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('TriggerWebhookNode', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// The node should expose the webhook URL and keep a clear fallback for empty data.
|
||||
describe('Rendering', () => {
|
||||
it('should render the webhook url when it exists', () => {
|
||||
renderNodeComponent(Node, createNodeData({
|
||||
webhook_url: 'https://example.com/webhook',
|
||||
}))
|
||||
|
||||
expect(screen.getByText('URL')).toBeInTheDocument()
|
||||
expect(screen.getByText('https://example.com/webhook')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the placeholder when the webhook url is empty', () => {
|
||||
renderNodeComponent(Node, createNodeData({
|
||||
webhook_url: '',
|
||||
}))
|
||||
|
||||
expect(screen.getByText('--')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,150 @@
|
||||
import type { WebhookTriggerNodeType } from '../types'
|
||||
import type { NodePanelProps } from '@/app/components/workflow/types'
|
||||
import type { PanelProps } from '@/types/workflow'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import Panel from '../panel'
|
||||
|
||||
const {
|
||||
mockHandleStatusCodeChange,
|
||||
mockGenerateWebhookUrl,
|
||||
mockHandleMethodChange,
|
||||
mockHandleContentTypeChange,
|
||||
mockHandleHeadersChange,
|
||||
mockHandleParamsChange,
|
||||
mockHandleBodyChange,
|
||||
mockHandleResponseBodyChange,
|
||||
} = vi.hoisted(() => ({
|
||||
mockHandleStatusCodeChange: vi.fn(),
|
||||
mockGenerateWebhookUrl: vi.fn(),
|
||||
mockHandleMethodChange: vi.fn(),
|
||||
mockHandleContentTypeChange: vi.fn(),
|
||||
mockHandleHeadersChange: vi.fn(),
|
||||
mockHandleParamsChange: vi.fn(),
|
||||
mockHandleBodyChange: vi.fn(),
|
||||
mockHandleResponseBodyChange: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockConfigState = {
|
||||
readOnly: false,
|
||||
inputs: {
|
||||
method: 'POST',
|
||||
webhook_url: 'https://example.com/webhook',
|
||||
webhook_debug_url: '',
|
||||
content_type: 'application/json',
|
||||
headers: [],
|
||||
params: [],
|
||||
body: [],
|
||||
status_code: 200,
|
||||
response_body: 'ok',
|
||||
variables: [],
|
||||
},
|
||||
}
|
||||
|
||||
vi.mock('../use-config', () => ({
|
||||
DEFAULT_STATUS_CODE: 200,
|
||||
MAX_STATUS_CODE: 399,
|
||||
normalizeStatusCode: (statusCode: number) => Math.min(Math.max(statusCode, 200), 399),
|
||||
useConfig: () => ({
|
||||
readOnly: mockConfigState.readOnly,
|
||||
inputs: mockConfigState.inputs,
|
||||
handleMethodChange: mockHandleMethodChange,
|
||||
handleContentTypeChange: mockHandleContentTypeChange,
|
||||
handleHeadersChange: mockHandleHeadersChange,
|
||||
handleParamsChange: mockHandleParamsChange,
|
||||
handleBodyChange: mockHandleBodyChange,
|
||||
handleStatusCodeChange: mockHandleStatusCodeChange,
|
||||
handleResponseBodyChange: mockHandleResponseBodyChange,
|
||||
generateWebhookUrl: mockGenerateWebhookUrl,
|
||||
}),
|
||||
}))
|
||||
|
||||
const getStatusCodeInput = () => {
|
||||
return screen.getAllByDisplayValue('200')
|
||||
.find(element => element.getAttribute('aria-hidden') !== 'true') as HTMLInputElement
|
||||
}
|
||||
|
||||
describe('WebhookTriggerPanel', () => {
|
||||
const panelProps: NodePanelProps<WebhookTriggerNodeType> = {
|
||||
id: 'node-1',
|
||||
data: {
|
||||
title: 'Webhook',
|
||||
desc: 'Webhook',
|
||||
type: BlockEnum.TriggerWebhook,
|
||||
method: 'POST',
|
||||
content_type: 'application/json',
|
||||
headers: [],
|
||||
params: [],
|
||||
body: [],
|
||||
async_mode: false,
|
||||
status_code: 200,
|
||||
response_body: 'ok',
|
||||
variables: [],
|
||||
},
|
||||
panelProps: {} as PanelProps,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockConfigState.readOnly = false
|
||||
mockConfigState.inputs = {
|
||||
method: 'POST',
|
||||
webhook_url: 'https://example.com/webhook',
|
||||
webhook_debug_url: '',
|
||||
content_type: 'application/json',
|
||||
headers: [],
|
||||
params: [],
|
||||
body: [],
|
||||
status_code: 200,
|
||||
response_body: 'ok',
|
||||
variables: [],
|
||||
}
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render the real panel fields without generating a new webhook url when one already exists', () => {
|
||||
render(<Panel {...panelProps} />)
|
||||
|
||||
expect(screen.getByDisplayValue('https://example.com/webhook')).toBeInTheDocument()
|
||||
expect(screen.getByText('application/json')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('ok')).toBeInTheDocument()
|
||||
expect(mockGenerateWebhookUrl).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should request a webhook url when the node is writable and missing one', async () => {
|
||||
mockConfigState.inputs = {
|
||||
...mockConfigState.inputs,
|
||||
webhook_url: '',
|
||||
}
|
||||
|
||||
render(<Panel {...panelProps} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGenerateWebhookUrl).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Status Code Input', () => {
|
||||
it('should update the status code when users enter a parseable value', () => {
|
||||
render(<Panel {...panelProps} />)
|
||||
|
||||
fireEvent.change(getStatusCodeInput(), { target: { value: '201' } })
|
||||
|
||||
expect(mockHandleStatusCodeChange).toHaveBeenCalledWith(201)
|
||||
})
|
||||
|
||||
it('should ignore clear changes until the value is committed', () => {
|
||||
render(<Panel {...panelProps} />)
|
||||
|
||||
const input = getStatusCodeInput()
|
||||
fireEvent.change(input, { target: { value: '' } })
|
||||
|
||||
expect(mockHandleStatusCodeChange).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect(mockHandleStatusCodeChange).toHaveBeenCalledWith(200)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -6,11 +6,18 @@ import copy from 'copy-to-clipboard'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { InputNumber } from '@/app/components/base/input-number'
|
||||
import InputWithCopy from '@/app/components/base/input-with-copy'
|
||||
import { SimpleSelect } from '@/app/components/base/select'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import {
|
||||
NumberField,
|
||||
NumberFieldControls,
|
||||
NumberFieldDecrement,
|
||||
NumberFieldGroup,
|
||||
NumberFieldIncrement,
|
||||
NumberFieldInput,
|
||||
} from '@/app/components/base/ui/number-field'
|
||||
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'
|
||||
@ -18,7 +25,7 @@ import { isPrivateOrLocalAddress } from '@/utils/urlValidation'
|
||||
import HeaderTable from './components/header-table'
|
||||
import ParagraphInput from './components/paragraph-input'
|
||||
import ParameterTable from './components/parameter-table'
|
||||
import useConfig from './use-config'
|
||||
import { DEFAULT_STATUS_CODE, MAX_STATUS_CODE, normalizeStatusCode, useConfig } from './use-config'
|
||||
import { OutputVariablesContent } from './utils/render-output-vars'
|
||||
|
||||
const i18nPrefix = 'nodes.triggerWebhook'
|
||||
@ -56,7 +63,6 @@ const Panel: FC<NodePanelProps<WebhookTriggerNodeType>> = ({
|
||||
handleParamsChange,
|
||||
handleBodyChange,
|
||||
handleStatusCodeChange,
|
||||
handleStatusCodeBlur,
|
||||
handleResponseBodyChange,
|
||||
generateWebhookUrl,
|
||||
} = useConfig(id, data)
|
||||
@ -197,19 +203,29 @@ const Panel: FC<NodePanelProps<WebhookTriggerNodeType>> = ({
|
||||
<label className="text-text-tertiary system-sm-medium">
|
||||
{t(`${i18nPrefix}.statusCode`, { ns: 'workflow' })}
|
||||
</label>
|
||||
<InputNumber
|
||||
value={inputs.status_code}
|
||||
onChange={(value) => {
|
||||
handleStatusCodeChange(value || 200)
|
||||
}}
|
||||
<NumberField
|
||||
className="w-[120px]"
|
||||
min={DEFAULT_STATUS_CODE}
|
||||
max={MAX_STATUS_CODE}
|
||||
value={inputs.status_code ?? DEFAULT_STATUS_CODE}
|
||||
disabled={readOnly}
|
||||
wrapClassName="w-[120px]"
|
||||
className="h-8"
|
||||
defaultValue={200}
|
||||
onBlur={() => {
|
||||
handleStatusCodeBlur(inputs.status_code)
|
||||
onValueChange={value => value !== null && handleStatusCodeChange(value)}
|
||||
onValueCommitted={(value, eventDetails) => {
|
||||
if (eventDetails.reason === 'input-blur' || eventDetails.reason === 'input-clear')
|
||||
handleStatusCodeChange(normalizeStatusCode(value ?? DEFAULT_STATUS_CODE))
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<NumberFieldGroup size="regular">
|
||||
<NumberFieldInput
|
||||
size="regular"
|
||||
className="h-8"
|
||||
/>
|
||||
<NumberFieldControls>
|
||||
<NumberFieldIncrement size="regular" />
|
||||
<NumberFieldDecrement size="regular" />
|
||||
</NumberFieldControls>
|
||||
</NumberFieldGroup>
|
||||
</NumberField>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-text-tertiary system-sm-medium">
|
||||
|
||||
@ -13,7 +13,11 @@ import { fetchWebhookUrl } from '@/service/apps'
|
||||
import { checkKeys, hasDuplicateStr } from '@/utils/var'
|
||||
import { WEBHOOK_RAW_VARIABLE_NAME } from './utils/raw-variable'
|
||||
|
||||
const useConfig = (id: string, payload: WebhookTriggerNodeType) => {
|
||||
export const DEFAULT_STATUS_CODE = 200
|
||||
export const MAX_STATUS_CODE = 399
|
||||
export const normalizeStatusCode = (statusCode: number) => Math.min(Math.max(statusCode, DEFAULT_STATUS_CODE), MAX_STATUS_CODE)
|
||||
|
||||
export const useConfig = (id: string, payload: WebhookTriggerNodeType) => {
|
||||
const { t } = useTranslation()
|
||||
const { nodesReadOnly: readOnly } = useNodesReadOnly()
|
||||
const { inputs, setInputs } = useNodeCrud<WebhookTriggerNodeType>(id, payload)
|
||||
@ -192,15 +196,6 @@ const useConfig = (id: string, payload: WebhookTriggerNodeType) => {
|
||||
}))
|
||||
}, [inputs, setInputs])
|
||||
|
||||
const handleStatusCodeBlur = useCallback((statusCode: number) => {
|
||||
// Only clamp when user finishes editing (on blur)
|
||||
const clampedStatusCode = Math.min(Math.max(statusCode, 200), 399)
|
||||
|
||||
setInputs(produce(inputs, (draft) => {
|
||||
draft.status_code = clampedStatusCode
|
||||
}))
|
||||
}, [inputs, setInputs])
|
||||
|
||||
const handleResponseBodyChange = useCallback((responseBody: string) => {
|
||||
setInputs(produce(inputs, (draft) => {
|
||||
draft.response_body = responseBody
|
||||
@ -247,10 +242,7 @@ const useConfig = (id: string, payload: WebhookTriggerNodeType) => {
|
||||
handleBodyChange,
|
||||
handleAsyncModeChange,
|
||||
handleStatusCodeChange,
|
||||
handleStatusCodeBlur,
|
||||
handleResponseBodyChange,
|
||||
generateWebhookUrl,
|
||||
}
|
||||
}
|
||||
|
||||
export default useConfig
|
||||
|
||||
Reference in New Issue
Block a user