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:
Novice
2026-03-23 14:20:06 +08:00
1671 changed files with 124822 additions and 22302 deletions

View File

@ -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()
})
})
})

View File

@ -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)
})
})
})

View File

@ -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">

View File

@ -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