From d522350c99bda6766d9d019d8fc0d463a92275dd Mon Sep 17 00:00:00 2001 From: cathy <38449456+CathyL0@users.noreply.github.com> Date: Tue, 2 Sep 2025 23:20:12 +0800 Subject: [PATCH] fix(webhook-trigger): request array type adjustment (#25005) --- .../components/app/overview/trigger-card.tsx | 4 +- .../workflow/header/run-and-history.tsx | 2 +- .../hooks/use-dynamic-test-run-options.tsx | 4 +- .../workflow/hooks/use-nodes-interactions.ts | 2 +- .../workflow/nodes/_base/components/field.tsx | 2 +- .../__tests__/monthly-multiselect.test.ts | 2 +- .../__tests__/weekly-time-logic.test.ts | 4 +- .../nodes/trigger-schedule/default.ts | 10 +- .../trigger-schedule/utils/cron-parser.ts | 8 +- .../components/parameter-table.tsx | 76 +------- .../workflow/nodes/trigger-webhook/default.ts | 30 ++- .../workflow/nodes/trigger-webhook/panel.tsx | 2 +- .../workflow/nodes/trigger-webhook/types.ts | 13 +- .../nodes/trigger-webhook/use-config.ts | 40 +++- .../utils/parameter-type-utils.test.ts | 149 ++++++++++++++ .../utils/parameter-type-utils.ts | 181 ++++++++++++++++++ web/app/components/workflow/utils/workflow.ts | 2 +- 17 files changed, 426 insertions(+), 105 deletions(-) create mode 100644 web/app/components/workflow/nodes/trigger-webhook/utils/parameter-type-utils.test.ts create mode 100644 web/app/components/workflow/nodes/trigger-webhook/utils/parameter-type-utils.ts diff --git a/web/app/components/app/overview/trigger-card.tsx b/web/app/components/app/overview/trigger-card.tsx index c6a4ad55f4..f9ebcbed46 100644 --- a/web/app/components/app/overview/trigger-card.tsx +++ b/web/app/components/app/overview/trigger-card.tsx @@ -28,7 +28,7 @@ const getTriggerIcon = (trigger: AppTrigger) => {
) } - else { + else { return (
) @@ -96,7 +96,7 @@ function TriggerCard({ appInfo }: ITriggerCardProps) { }) invalidateAppTriggers(appId) } - catch (error) { + catch (error) { console.error('Failed to update trigger status:', error) } } diff --git a/web/app/components/workflow/header/run-and-history.tsx b/web/app/components/workflow/header/run-and-history.tsx index 348dc931b1..6626f7f226 100644 --- a/web/app/components/workflow/header/run-and-history.tsx +++ b/web/app/components/workflow/header/run-and-history.tsx @@ -61,7 +61,7 @@ const RunMode = memo(() => { if (option.type === 'user_input') { handleWorkflowStartRunInWorkflow() } - else { + else { // Placeholder for trigger-specific execution logic for schedule, webhook, plugin types console.log('TODO: Handle trigger execution for type:', option.type, 'nodeId:', option.nodeId) } diff --git a/web/app/components/workflow/hooks/use-dynamic-test-run-options.tsx b/web/app/components/workflow/hooks/use-dynamic-test-run-options.tsx index fdc854411d..cba3a9cd2e 100644 --- a/web/app/components/workflow/hooks/use-dynamic-test-run-options.tsx +++ b/web/app/components/workflow/hooks/use-dynamic-test-run-options.tsx @@ -102,7 +102,7 @@ export const useDynamicTestRunOptions = (): TestRunOptions => { /> ) } - else if (toolIcon && typeof toolIcon === 'object' && 'content' in toolIcon) { + else if (toolIcon && typeof toolIcon === 'object' && 'content' in toolIcon) { icon = ( { /> ) } - else { + else { icon = (
P diff --git a/web/app/components/workflow/hooks/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts index b45ce8831f..33fd9cdeb3 100644 --- a/web/app/components/workflow/hooks/use-nodes-interactions.ts +++ b/web/app/components/workflow/hooks/use-nodes-interactions.ts @@ -114,7 +114,7 @@ export const useNodesInteractions = () => { try { return checkNestedParallelLimit(nodes, edges, parentNodeId) } - catch (error: any) { + catch (error: any) { if (handleStartNodeMissingError(error, operationKey)) return false // Operation blocked but gracefully handled diff --git a/web/app/components/workflow/nodes/_base/components/field.tsx b/web/app/components/workflow/nodes/_base/components/field.tsx index d82ea027fb..aadcea1065 100644 --- a/web/app/components/workflow/nodes/_base/components/field.tsx +++ b/web/app/components/workflow/nodes/_base/components/field.tsx @@ -38,7 +38,7 @@ const Field: FC = ({
supportFold && toggleFold()} - className={cn('sticky top-0 z-10 flex items-center justify-between bg-components-panel-bg', supportFold && 'cursor-pointer')}> + className={cn('flex items-center justify-between', supportFold && 'cursor-pointer')}>
{title} {required && *} diff --git a/web/app/components/workflow/nodes/trigger-schedule/__tests__/monthly-multiselect.test.ts b/web/app/components/workflow/nodes/trigger-schedule/__tests__/monthly-multiselect.test.ts index f9d609d372..f2f1bbedf8 100644 --- a/web/app/components/workflow/nodes/trigger-schedule/__tests__/monthly-multiselect.test.ts +++ b/web/app/components/workflow/nodes/trigger-schedule/__tests__/monthly-multiselect.test.ts @@ -116,7 +116,7 @@ describe('Monthly Multi-Select Execution Time Calculator', () => { time: '10:30 AM', }, timezone: 'UTC', - id: 'test', + id: 'test', type: 'trigger-schedule', data: {}, position: { x: 0, y: 0 }, diff --git a/web/app/components/workflow/nodes/trigger-schedule/__tests__/weekly-time-logic.test.ts b/web/app/components/workflow/nodes/trigger-schedule/__tests__/weekly-time-logic.test.ts index 30dc11d602..a6c00d069f 100644 --- a/web/app/components/workflow/nodes/trigger-schedule/__tests__/weekly-time-logic.test.ts +++ b/web/app/components/workflow/nodes/trigger-schedule/__tests__/weekly-time-logic.test.ts @@ -363,7 +363,7 @@ describe('Weekly Schedule Time Logic Tests', () => { time: '2:00 PM', }, timezone: 'UTC', - } + } const weeklyTimes = getNextExecutionTimes(weeklyConfig, 1) const dailyTimes = getNextExecutionTimes(dailyConfig, 1) @@ -387,7 +387,7 @@ describe('Weekly Schedule Time Logic Tests', () => { time: '2:00 PM', }, timezone: 'UTC', - } + } const weeklyTimes = getNextExecutionTimes(weeklyConfig, 1) const dailyTimes = getNextExecutionTimes(dailyConfig, 1) diff --git a/web/app/components/workflow/nodes/trigger-schedule/default.ts b/web/app/components/workflow/nodes/trigger-schedule/default.ts index 4c5991aa80..f928686f4f 100644 --- a/web/app/components/workflow/nodes/trigger-schedule/default.ts +++ b/web/app/components/workflow/nodes/trigger-schedule/default.ts @@ -130,7 +130,7 @@ const nodeDefault: NodeDefault = { try { Intl.DateTimeFormat(undefined, { timeZone: payload.timezone }) } - catch { + catch { errorMessages = t('workflow.nodes.triggerSchedule.invalidTimezone') } } @@ -138,13 +138,13 @@ const nodeDefault: NodeDefault = { if (payload.mode === 'cron') { if (!payload.cron_expression || payload.cron_expression.trim() === '') errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.triggerSchedule.cronExpression') }) - else if (!isValidCronExpression(payload.cron_expression)) + else if (!isValidCronExpression(payload.cron_expression)) errorMessages = t('workflow.nodes.triggerSchedule.invalidCronExpression') } - else if (payload.mode === 'visual') { + else if (payload.mode === 'visual') { if (!payload.frequency) errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.triggerSchedule.frequency') }) - else + else errorMessages = validateVisualConfig(payload, t) } } @@ -154,7 +154,7 @@ const nodeDefault: NodeDefault = { if (nextTimes.length === 0) errorMessages = t('workflow.nodes.triggerSchedule.noValidExecutionTime') } - catch { + catch { errorMessages = t('workflow.nodes.triggerSchedule.executionTimeCalculationError') } } diff --git a/web/app/components/workflow/nodes/trigger-schedule/utils/cron-parser.ts b/web/app/components/workflow/nodes/trigger-schedule/utils/cron-parser.ts index d20b6cc0c7..1f63fab8d2 100644 --- a/web/app/components/workflow/nodes/trigger-schedule/utils/cron-parser.ts +++ b/web/app/components/workflow/nodes/trigger-schedule/utils/cron-parser.ts @@ -12,7 +12,7 @@ const matchesField = (value: number, pattern: string, min: number, max: number): if (range === '*') { return value % stepValue === min % stepValue } - else { + else { const rangeStart = Number.parseInt(range, 10) if (Number.isNaN(rangeStart)) return false return value >= rangeStart && (value - rangeStart) % stepValue === 0 @@ -96,15 +96,15 @@ const matchesCron = ( return matchesField(currentDay, dayOfMonth, 1, 31) || matchesField(currentDayOfWeek, dayOfWeek, 0, 6) } - else if (dayOfMonthSpecified) { + else if (dayOfMonthSpecified) { // Only day of month specified return matchesField(currentDay, dayOfMonth, 1, 31) } - else if (dayOfWeekSpecified) { + else if (dayOfWeekSpecified) { // Only day of week specified return matchesField(currentDayOfWeek, dayOfWeek, 0, 6) } - else { + else { // Both are *, matches any day return true } 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 index 7268e9e38d..482cd672ee 100644 --- a/web/app/components/workflow/nodes/trigger-webhook/components/parameter-table.tsx +++ b/web/app/components/workflow/nodes/trigger-webhook/components/parameter-table.tsx @@ -5,20 +5,7 @@ import { useTranslation } from 'react-i18next' import GenericTable from './generic-table' import type { ColumnConfig, GenericTableRow } from './generic-table' import type { ParameterType, WebhookParameter } from '../types' - -const normalizeParamType = (type: string): ParameterType => { - switch (type) { - case 'string': - case 'number': - case 'boolean': - case 'array': - case 'object': - case 'file': - return type - default: - return 'string' - } -} +import { createParameterTypeOptions, normalizeParameterType } from '../utils/parameter-type-utils' type ParameterTableProps = { title: string @@ -43,59 +30,7 @@ const ParameterTable: FC = ({ }) => { const { t } = useTranslation() - const resolveTypeOptions = (): { name: string; value: ParameterType }[] => { - if (!isRequestBody) { - return [ - { name: 'String', value: 'string' }, - ] - } - - const ct = (contentType || '').toLowerCase() - // application/json -> all JSON-friendly types - if (ct === 'application/json') { - return [ - { name: 'String', value: 'string' }, - { name: 'Number', value: 'number' }, - { name: 'Boolean', value: 'boolean' }, - { name: 'Array', value: 'array' }, - { name: 'Object', value: 'object' }, - ] - } - // text/plain -> plain text only - if (ct === 'text/plain') { - return [ - { name: 'String', value: 'string' }, - ] - } - // x-www-form-urlencoded and forms -> simple key-value pairs - if (ct === 'application/x-www-form-urlencoded' || ct === 'forms') { - return [ - { name: 'String', value: 'string' }, - { name: 'Number', value: 'number' }, - { name: 'Boolean', value: 'boolean' }, - ] - } - // multipart/form-data -> allow file additionally - if (ct === 'multipart/form-data') { - return [ - { name: 'String', value: 'string' }, - { name: 'Number', value: 'number' }, - { name: 'Boolean', value: 'boolean' }, - { name: 'File', value: 'file' }, - ] - } - - // Fallback: all json-like types - return [ - { name: 'String', value: 'string' }, - { name: 'Number', value: 'number' }, - { name: 'Boolean', value: 'boolean' }, - { name: 'Array', value: 'array' }, - { name: 'Object', value: 'object' }, - ] - } - - const typeOptions = resolveTypeOptions() + const typeOptions = createParameterTypeOptions(contentType, isRequestBody) // Define columns based on component type - matching prototype design const columns: ColumnConfig[] = [ @@ -125,10 +60,7 @@ const ParameterTable: FC = ({ ] // Choose sensible default type for new rows according to content type - const defaultTypeValue = ((): ParameterType => { - const first = typeOptions[0]?.value - return first || 'string' - })() + const defaultTypeValue: ParameterType = typeOptions[0]?.value || 'string' // Empty row template for new rows const emptyRowData: GenericTableRow = { @@ -151,7 +83,7 @@ const ParameterTable: FC = ({ .filter(row => typeof row.key === 'string' && (row.key as string).trim() !== '') .map(row => ({ name: String(row.key), - type: isTextPlain ? 'string' : normalizeParamType((row.type as string) || 'string'), + type: isTextPlain ? 'string' : normalizeParameterType((row.type as string) || 'string'), required: Boolean(row.required), })) diff --git a/web/app/components/workflow/nodes/trigger-webhook/default.ts b/web/app/components/workflow/nodes/trigger-webhook/default.ts index 2b254de943..1d54fa15fd 100644 --- a/web/app/components/workflow/nodes/trigger-webhook/default.ts +++ b/web/app/components/workflow/nodes/trigger-webhook/default.ts @@ -1,6 +1,7 @@ import { BlockEnum } from '../../types' import type { NodeDefault } from '../../types' import type { WebhookTriggerNodeType } from './types' +import { isValidParameterType } from './utils/parameter-type-utils' import { ALL_COMPLETION_AVAILABLE_BLOCKS } from '@/app/components/workflow/blocks' const nodeDefault: NodeDefault = { @@ -24,7 +25,34 @@ const nodeDefault: NodeDefault = { : ALL_COMPLETION_AVAILABLE_BLOCKS return nodes.filter(type => type !== BlockEnum.Start) }, - checkValid(_payload: WebhookTriggerNodeType, _t: any) { + checkValid(payload: WebhookTriggerNodeType, t: any) { + // Validate webhook configuration + if (!payload.webhook_url) { + return { + isValid: false, + errorMessage: t('workflow.nodes.webhook.validation.webhookUrlRequired'), + } + } + + // Validate parameter types for params and body + const parametersWithTypes = [ + ...(payload.params || []), + ...(payload.body || []), + ] + + for (const param of parametersWithTypes) { + // Validate parameter type is valid + if (!isValidParameterType(param.type)) { + return { + isValid: false, + errorMessage: t('workflow.nodes.webhook.validation.invalidParameterType', { + name: param.name, + type: param.type, + }), + } + } + } + return { isValid: true, errorMessage: '', diff --git a/web/app/components/workflow/nodes/trigger-webhook/panel.tsx b/web/app/components/workflow/nodes/trigger-webhook/panel.tsx index aeea0eec72..4a6dfb77a0 100644 --- a/web/app/components/workflow/nodes/trigger-webhook/panel.tsx +++ b/web/app/components/workflow/nodes/trigger-webhook/panel.tsx @@ -173,7 +173,7 @@ const Panel: FC> = ({ placeholder={t(`${i18nPrefix}.noBodyParameters`)} showType={true} isRequestBody={true} - contentType={inputs['content-type']} + contentType={inputs.content_type} /> diff --git a/web/app/components/workflow/nodes/trigger-webhook/types.ts b/web/app/components/workflow/nodes/trigger-webhook/types.ts index 8d27622bc5..587e9e5ebb 100644 --- a/web/app/components/workflow/nodes/trigger-webhook/types.ts +++ b/web/app/components/workflow/nodes/trigger-webhook/types.ts @@ -2,7 +2,18 @@ import type { CommonNodeType, InputVar } from '@/app/components/workflow/types' export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' -export type ParameterType = 'string' | 'number' | 'boolean' | 'array' | 'object' | 'file' +export type ParameterType = 'string' | 'number' | 'boolean' | 'array[string]' | 'array[number]' | 'array[boolean]' | 'array[object]' | 'object' | 'file' + +export type ArrayElementType = 'string' | 'number' | 'boolean' | 'object' + +export const isArrayType = (type: ParameterType): type is `array[${ArrayElementType}]` => { + return type.startsWith('array[') && type.endsWith(']') +} + +export const getArrayElementType = (arrayType: `array[${ArrayElementType}]`): ArrayElementType => { + const match = arrayType.match(/^array\[(.+)\]$/) + return (match?.[1] as ArrayElementType) || 'string' +} export type WebhookParameter = { name: string diff --git a/web/app/components/workflow/nodes/trigger-webhook/use-config.ts b/web/app/components/workflow/nodes/trigger-webhook/use-config.ts index 7c217d2c17..4faf139ccf 100644 --- a/web/app/components/workflow/nodes/trigger-webhook/use-config.ts +++ b/web/app/components/workflow/nodes/trigger-webhook/use-config.ts @@ -1,7 +1,9 @@ import { useCallback } from 'react' import produce from 'immer' import { useTranslation } from 'react-i18next' -import type { HttpMethod, WebhookHeader, WebhookParameter, WebhookTriggerNodeType } from './types' +import type { HttpMethod, ParameterType, WebhookHeader, WebhookParameter, WebhookTriggerNodeType } from './types' +import { getArrayElementType, isArrayType } 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' @@ -30,13 +32,31 @@ const useConfig = (id: string, payload: WebhookTriggerNodeType) => { }, [inputs, setInputs]) // Helper function to convert ParameterType to InputVarType - const toInputVarType = useCallback((type: string): InputVarType => { + const toInputVarType = useCallback((type: ParameterType): InputVarType => { + // Handle specific array types + if (isArrayType(type)) { + const elementType = getArrayElementType(type) + switch (elementType) { + case 'string': + return InputVarType.textInput + case 'number': + return InputVarType.number + case 'boolean': + return InputVarType.checkbox + case 'object': + return InputVarType.jsonObject + default: + return InputVarType.textInput + } + } + + // Handle non-array types const typeMap: Record = { string: InputVarType.textInput, number: InputVarType.number, boolean: InputVarType.checkbox, - array: InputVarType.textInput, // Arrays as text for now object: InputVarType.jsonObject, + file: InputVarType.singleFile, } return typeMap[type] || InputVarType.textInput }, []) @@ -49,12 +69,12 @@ const useConfig = (id: string, payload: WebhookTriggerNodeType) => { draft.variables = [] if(hasDuplicateStr(newData.map(item => item.name))) { - Toast.notify({ - type: 'error', - message: t('appDebug.varKeyError.keyAlreadyExists', { - key: t('appDebug.variableConfig.varName'), - }), - }) + Toast.notify({ + type: 'error', + message: t('appDebug.varKeyError.keyAlreadyExists', { + key: t('appDebug.variableConfig.varName'), + }), + }) return false } @@ -76,7 +96,7 @@ const useConfig = (id: string, payload: WebhookTriggerNodeType) => { if (existingVarIndex >= 0) draft.variables[existingVarIndex] = newVar - else + else draft.variables.push(newVar) }) diff --git a/web/app/components/workflow/nodes/trigger-webhook/utils/parameter-type-utils.test.ts b/web/app/components/workflow/nodes/trigger-webhook/utils/parameter-type-utils.test.ts new file mode 100644 index 0000000000..f073b185c6 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-webhook/utils/parameter-type-utils.test.ts @@ -0,0 +1,149 @@ +import { + createParameterTypeOptions, + getAvailableParameterTypes, + getParameterTypeDisplayName, + isValidParameterType, + normalizeParameterType, + validateParameterValue, +} from './parameter-type-utils' + +describe('Parameter Type Utils', () => { + describe('isValidParameterType', () => { + it('should validate specific array types', () => { + expect(isValidParameterType('array[string]')).toBe(true) + expect(isValidParameterType('array[number]')).toBe(true) + expect(isValidParameterType('array[boolean]')).toBe(true) + expect(isValidParameterType('array[object]')).toBe(true) + }) + + it('should validate basic types', () => { + expect(isValidParameterType('string')).toBe(true) + expect(isValidParameterType('number')).toBe(true) + expect(isValidParameterType('boolean')).toBe(true) + expect(isValidParameterType('object')).toBe(true) + expect(isValidParameterType('file')).toBe(true) + }) + + it('should reject invalid types', () => { + expect(isValidParameterType('array')).toBe(false) + expect(isValidParameterType('invalid')).toBe(false) + expect(isValidParameterType('array[invalid]')).toBe(false) + }) + }) + + describe('normalizeParameterType', () => { + it('should normalize valid types', () => { + expect(normalizeParameterType('string')).toBe('string') + expect(normalizeParameterType('array[string]')).toBe('array[string]') + }) + + it('should migrate legacy array type', () => { + expect(normalizeParameterType('array')).toBe('array[string]') + }) + + it('should default to string for invalid types', () => { + expect(normalizeParameterType('invalid')).toBe('string') + }) + }) + + describe('getParameterTypeDisplayName', () => { + it('should return correct display names for array types', () => { + expect(getParameterTypeDisplayName('array[string]')).toBe('Array[String]') + expect(getParameterTypeDisplayName('array[number]')).toBe('Array[Number]') + expect(getParameterTypeDisplayName('array[boolean]')).toBe('Array[Boolean]') + expect(getParameterTypeDisplayName('array[object]')).toBe('Array[Object]') + }) + + it('should return correct display names for basic types', () => { + expect(getParameterTypeDisplayName('string')).toBe('String') + expect(getParameterTypeDisplayName('number')).toBe('Number') + expect(getParameterTypeDisplayName('boolean')).toBe('Boolean') + expect(getParameterTypeDisplayName('object')).toBe('Object') + expect(getParameterTypeDisplayName('file')).toBe('File') + }) + }) + + describe('validateParameterValue', () => { + it('should validate string values', () => { + expect(validateParameterValue('test', 'string')).toBe(true) + expect(validateParameterValue('', 'string')).toBe(true) + expect(validateParameterValue(123, 'string')).toBe(false) + }) + + it('should validate number values', () => { + expect(validateParameterValue(123, 'number')).toBe(true) + expect(validateParameterValue(123.45, 'number')).toBe(true) + expect(validateParameterValue('abc', 'number')).toBe(false) + expect(validateParameterValue(Number.NaN, 'number')).toBe(false) + }) + + it('should validate boolean values', () => { + expect(validateParameterValue(true, 'boolean')).toBe(true) + expect(validateParameterValue(false, 'boolean')).toBe(true) + expect(validateParameterValue('true', 'boolean')).toBe(false) + }) + + it('should validate array values', () => { + expect(validateParameterValue(['a', 'b'], 'array[string]')).toBe(true) + expect(validateParameterValue([1, 2, 3], 'array[number]')).toBe(true) + expect(validateParameterValue([true, false], 'array[boolean]')).toBe(true) + expect(validateParameterValue([{ key: 'value' }], 'array[object]')).toBe(true) + expect(validateParameterValue(['a', 1], 'array[string]')).toBe(false) + expect(validateParameterValue('not an array', 'array[string]')).toBe(false) + }) + + it('should validate object values', () => { + expect(validateParameterValue({ key: 'value' }, 'object')).toBe(true) + expect(validateParameterValue({}, 'object')).toBe(true) + expect(validateParameterValue(null, 'object')).toBe(false) + expect(validateParameterValue([], 'object')).toBe(false) + expect(validateParameterValue('string', 'object')).toBe(false) + }) + + it('should validate file values', () => { + const mockFile = new File(['content'], 'test.txt', { type: 'text/plain' }) + expect(validateParameterValue(mockFile, 'file')).toBe(true) + expect(validateParameterValue({ name: 'file.txt' }, 'file')).toBe(true) + expect(validateParameterValue('not a file', 'file')).toBe(false) + }) + + it('should return false for invalid types', () => { + expect(validateParameterValue('test', 'invalid' as any)).toBe(false) + }) + }) + + describe('getAvailableParameterTypes', () => { + it('should return only string for non-request body', () => { + const types = getAvailableParameterTypes('application/json', false) + expect(types).toEqual(['string']) + }) + + it('should return all types for application/json', () => { + const types = getAvailableParameterTypes('application/json', true) + expect(types).toContain('string') + expect(types).toContain('number') + expect(types).toContain('boolean') + expect(types).toContain('array[string]') + expect(types).toContain('array[number]') + expect(types).toContain('array[boolean]') + expect(types).toContain('array[object]') + expect(types).toContain('object') + }) + + it('should include file type for multipart/form-data', () => { + const types = getAvailableParameterTypes('multipart/form-data', true) + expect(types).toContain('file') + }) + }) + + describe('createParameterTypeOptions', () => { + it('should create options with display names', () => { + const options = createParameterTypeOptions('application/json', true) + const stringOption = options.find(opt => opt.value === 'string') + const arrayStringOption = options.find(opt => opt.value === 'array[string]') + + expect(stringOption?.name).toBe('String') + expect(arrayStringOption?.name).toBe('Array[String]') + }) + }) +}) diff --git a/web/app/components/workflow/nodes/trigger-webhook/utils/parameter-type-utils.ts b/web/app/components/workflow/nodes/trigger-webhook/utils/parameter-type-utils.ts new file mode 100644 index 0000000000..c0acee2a79 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-webhook/utils/parameter-type-utils.ts @@ -0,0 +1,181 @@ +import type { ArrayElementType, ParameterType } from '../types' + +// Constants for better maintainability and reusability +const BASIC_TYPES = ['string', 'number', 'boolean', 'object', 'file'] as const +const ARRAY_ELEMENT_TYPES = ['string', 'number', 'boolean', 'object'] as const + +// Generate all valid parameter types programmatically +const VALID_PARAMETER_TYPES: readonly ParameterType[] = [ + ...BASIC_TYPES, + ...ARRAY_ELEMENT_TYPES.map(type => `array[${type}]` as const), +] as const + +// Type display name mappings +const TYPE_DISPLAY_NAMES: Record = { + 'string': 'String', + 'number': 'Number', + 'boolean': 'Boolean', + 'object': 'Object', + 'file': 'File', + 'array[string]': 'Array[String]', + 'array[number]': 'Array[Number]', + 'array[boolean]': 'Array[Boolean]', + 'array[object]': 'Array[Object]', +} as const + +// Content type configurations +const CONTENT_TYPE_CONFIGS = { + 'application/json': { + supportedTypes: [...BASIC_TYPES.filter(t => t !== 'file'), ...ARRAY_ELEMENT_TYPES.map(t => `array[${t}]` as const)], + description: 'JSON supports all types including arrays', + }, + 'text/plain': { + supportedTypes: ['string'] as const, + description: 'Plain text only supports string', + }, + 'application/x-www-form-urlencoded': { + supportedTypes: ['string', 'number', 'boolean'] as const, + description: 'Form data supports basic types', + }, + 'forms': { + supportedTypes: ['string', 'number', 'boolean'] as const, + description: 'Form data supports basic types', + }, + 'multipart/form-data': { + supportedTypes: ['string', 'number', 'boolean', 'file'] as const, + description: 'Multipart supports basic types plus files', + }, +} as const + +/** + * Type guard to check if a string is a valid parameter type + */ +export const isValidParameterType = (type: string): type is ParameterType => { + return (VALID_PARAMETER_TYPES as readonly string[]).includes(type) +} + +/** + * Type-safe helper to check if a string is a valid array element type + */ +const isValidArrayElementType = (type: string): type is ArrayElementType => { + return (ARRAY_ELEMENT_TYPES as readonly string[]).includes(type) +} + +/** + * Type-safe helper to check if a string is a valid basic type + */ +const isValidBasicType = (type: string): type is Exclude => { + return (BASIC_TYPES as readonly string[]).includes(type) +} + +/** + * Normalizes parameter type from various input formats to the new type system + * Handles legacy 'array' type and malformed inputs gracefully + */ +export const normalizeParameterType = (input: string | undefined | null): ParameterType => { + if (!input || typeof input !== 'string') + return 'string' + + const trimmed = input.trim().toLowerCase() + + // Handle legacy array type + if (trimmed === 'array') + return 'array[string]' // Default to string array for backward compatibility + + // Handle specific array types + if (trimmed.startsWith('array[') && trimmed.endsWith(']')) { + const elementType = trimmed.slice(6, -1) // Extract content between 'array[' and ']' + + if (isValidArrayElementType(elementType)) + return `array[${elementType}]` + + // Invalid array element type, default to string array + return 'array[string]' + } + + // Handle basic types + if (isValidBasicType(trimmed)) + return trimmed + + // Fallback to string for unknown types + return 'string' +} + +/** + * Gets display name for parameter types in UI components + */ +export const getParameterTypeDisplayName = (type: ParameterType): string => { + return TYPE_DISPLAY_NAMES[type] +} + +// Type validation functions for better reusability +const validators = { + string: (value: unknown): value is string => typeof value === 'string', + number: (value: unknown): value is number => typeof value === 'number' && !isNaN(value), + boolean: (value: unknown): value is boolean => typeof value === 'boolean', + object: (value: unknown): value is object => + typeof value === 'object' && value !== null && !Array.isArray(value), + file: (value: unknown): value is File => + value instanceof File || (typeof value === 'object' && value !== null), +} as const + +/** + * Validates array elements based on element type + */ +const validateArrayElements = (value: unknown[], elementType: ArrayElementType): boolean => { + const validator = validators[elementType] + return value.every(item => validator(item)) +} + +/** + * Validates parameter value against its declared type + * Provides runtime type checking for webhook parameters + */ +export const validateParameterValue = (value: unknown, type: ParameterType): boolean => { + // Handle basic types + if (type in validators) { + const validator = validators[type as keyof typeof validators] + return validator(value) + } + + // Handle array types + if (type.startsWith('array[') && type.endsWith(']')) { + if (!Array.isArray(value)) return false + + const elementType = type.slice(6, -1) + return isValidArrayElementType(elementType) && validateArrayElements(value, elementType) + } + + return false +} + +/** + * Gets available parameter types based on content type + * Provides context-aware type filtering for different webhook content types + */ +export const getAvailableParameterTypes = (contentType?: string, isRequestBody = false): ParameterType[] => { + if (!isRequestBody) { + // Query parameters and headers are always strings + return ['string'] + } + + const normalizedContentType = (contentType || '').toLowerCase() + const configKey = normalizedContentType in CONTENT_TYPE_CONFIGS + ? normalizedContentType as keyof typeof CONTENT_TYPE_CONFIGS + : 'application/json' + + const config = CONTENT_TYPE_CONFIGS[configKey] + return [...config.supportedTypes] +} + +/** + * Creates type options for UI select components + */ +export const createParameterTypeOptions = (contentType?: string, isRequestBody = false) => { + const availableTypes = getAvailableParameterTypes(contentType, isRequestBody) + + return availableTypes.map(type => ({ + name: getParameterTypeDisplayName(type), + value: type, + })) +} diff --git a/web/app/components/workflow/utils/workflow.ts b/web/app/components/workflow/utils/workflow.ts index 5f95013673..16baf12152 100644 --- a/web/app/components/workflow/utils/workflow.ts +++ b/web/app/components/workflow/utils/workflow.ts @@ -134,7 +134,7 @@ export const getValidTreeNodes = (nodes: Node[], edges: Edge[]) => { } }) } - else { + else { // Leaf node - add iteration/loop children if any if (root.data.type === BlockEnum.Iteration) list.push(...nodes.filter(node => node.parentId === root.id))