feat: implement Schedule Trigger validation with multi-start node topology support (#24134)

This commit is contained in:
lyzno1 2025-08-19 11:55:15 +08:00 committed by GitHub
parent d4ff1e031a
commit f7bb3b852a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 439 additions and 21 deletions

View File

@ -131,19 +131,46 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => {
}
}
if (errorMessage || !validNodes.find(n => n.id === node.id)) {
// Start nodes and Trigger nodes should not show unConnected error if they have validation errors
// or if they are valid start nodes (even without incoming connections)
const isStartNode = node.data.type === BlockEnum.Start
|| node.data.type === BlockEnum.TriggerSchedule
|| node.data.type === BlockEnum.TriggerWebhook
|| node.data.type === BlockEnum.TriggerPlugin
const isUnconnected = !validNodes.find(n => n.id === node.id)
const shouldShowError = errorMessage || (isUnconnected && !isStartNode)
if (shouldShowError) {
list.push({
id: node.id,
type: node.data.type,
title: node.data.title,
toolIcon,
unConnected: !validNodes.find(n => n.id === node.id),
unConnected: isUnconnected && !isStartNode,
errorMessage,
})
}
}
}
// Check for start nodes (including triggers)
const startNodes = nodes.filter(node =>
node.data.type === BlockEnum.Start
|| node.data.type === BlockEnum.TriggerSchedule
|| node.data.type === BlockEnum.TriggerWebhook
|| node.data.type === BlockEnum.TriggerPlugin,
)
if (startNodes.length === 0) {
list.push({
id: 'start-node-required',
type: BlockEnum.Start,
title: t('workflow.blocks.start'),
errorMessage: t('workflow.common.needStartNode'),
})
}
if (isChatMode && !nodes.find(node => node.data.type === BlockEnum.Answer)) {
list.push({
id: 'answer-need-added',
@ -270,6 +297,18 @@ export const useChecklistBeforePublish = () => {
}
}
const startNodes = nodes.filter(node =>
node.data.type === BlockEnum.Start
|| node.data.type === BlockEnum.TriggerSchedule
|| node.data.type === BlockEnum.TriggerWebhook
|| node.data.type === BlockEnum.TriggerPlugin,
)
if (startNodes.length === 0) {
notify({ type: 'error', message: t('workflow.common.needStartNode') })
return false
}
if (isChatMode && !nodes.find(node => node.data.type === BlockEnum.Answer)) {
notify({ type: 'error', message: t('workflow.common.needAnswerNode') })
return false

View File

@ -0,0 +1,133 @@
/**
* Schedule Trigger Node Default Tests
*
* Simple test for the Schedule Trigger node default configuration and validation.
* Tests core checkValid functionality following project patterns.
*/
import nodeDefault from '../default'
import type { ScheduleTriggerNodeType } from '../types'
// Mock external dependencies
jest.mock('../utils/cron-parser', () => ({
isValidCronExpression: jest.fn((expr: string) => {
return expr === '0 9 * * 1' // Only this specific expression is valid
}),
}))
jest.mock('../utils/execution-time-calculator', () => ({
getNextExecutionTimes: jest.fn(() => [new Date(Date.now() + 86400000)]),
}))
// Simple mock translation function
const mockT = (key: string, params?: any) => {
if (key.includes('fieldRequired')) return `${params?.field} is required`
if (key.includes('invalidCronExpression')) return 'Invalid cron expression'
if (key.includes('invalidTimezone')) return 'Invalid timezone'
return key
}
describe('Schedule Trigger Node Default', () => {
describe('Basic Configuration', () => {
it('should have correct default value', () => {
expect(nodeDefault.defaultValue.mode).toBe('visual')
expect(nodeDefault.defaultValue.frequency).toBe('daily')
expect(nodeDefault.defaultValue.enabled).toBe(true)
})
it('should have empty prev nodes', () => {
const prevNodes = nodeDefault.getAvailablePrevNodes(false)
expect(prevNodes).toEqual([])
})
it('should have available next nodes excluding Start', () => {
const nextNodes = nodeDefault.getAvailableNextNodes(false)
expect(nextNodes).toBeDefined()
expect(nextNodes.length).toBeGreaterThan(0)
})
})
describe('Validation - checkValid', () => {
it('should validate successfully with valid visual config', () => {
const payload: ScheduleTriggerNodeType = {
mode: 'visual',
timezone: 'UTC',
frequency: 'daily',
visual_config: {
time: '9:00 AM',
},
}
const result = nodeDefault.checkValid(payload, mockT)
expect(result.isValid).toBe(true)
expect(result.errorMessage).toBe('')
})
it('should require mode field', () => {
const payload = {
timezone: 'UTC',
} as ScheduleTriggerNodeType
const result = nodeDefault.checkValid(payload, mockT)
expect(result.isValid).toBe(false)
expect(result.errorMessage).toContain('required')
})
it('should require timezone field', () => {
const payload = {
mode: 'visual',
} as ScheduleTriggerNodeType
const result = nodeDefault.checkValid(payload, mockT)
expect(result.isValid).toBe(false)
expect(result.errorMessage).toContain('required')
})
it('should validate cron mode with valid expression', () => {
const payload: ScheduleTriggerNodeType = {
mode: 'cron',
timezone: 'UTC',
cron_expression: '0 9 * * 1',
}
const result = nodeDefault.checkValid(payload, mockT)
expect(result.isValid).toBe(true)
})
it('should reject invalid cron expression', () => {
const payload: ScheduleTriggerNodeType = {
mode: 'cron',
timezone: 'UTC',
cron_expression: 'invalid',
}
const result = nodeDefault.checkValid(payload, mockT)
expect(result.isValid).toBe(false)
expect(result.errorMessage).toContain('Invalid cron expression')
})
it('should reject invalid timezone', () => {
const payload = {
mode: 'visual',
timezone: 'Invalid/Timezone',
frequency: 'daily',
visual_config: { time: '9:00 AM' },
} as ScheduleTriggerNodeType
const result = nodeDefault.checkValid(payload, mockT)
expect(result.isValid).toBe(false)
expect(result.errorMessage).toContain('Invalid timezone')
})
it('should require frequency in visual mode', () => {
const payload = {
mode: 'visual',
timezone: 'UTC',
} as ScheduleTriggerNodeType
const result = nodeDefault.checkValid(payload, mockT)
expect(result.isValid).toBe(false)
expect(result.errorMessage).toContain('required')
})
})
})

View File

@ -2,6 +2,131 @@ import { BlockEnum } from '../../types'
import type { NodeDefault } from '../../types'
import type { ScheduleTriggerNodeType } from './types'
import { ALL_CHAT_AVAILABLE_BLOCKS, ALL_COMPLETION_AVAILABLE_BLOCKS } from '@/app/components/workflow/blocks'
import { isValidCronExpression } from './utils/cron-parser'
import { getNextExecutionTimes } from './utils/execution-time-calculator'
const isValidTimeFormat = (time: string): boolean => {
const timeRegex = /^(0?\d|1[0-2]):[0-5]\d (AM|PM)$/
if (!timeRegex.test(time)) return false
const [timePart, period] = time.split(' ')
const [hour, minute] = timePart.split(':')
const hourNum = Number.parseInt(hour, 10)
const minuteNum = Number.parseInt(minute, 10)
return hourNum >= 1 && hourNum <= 12
&& minuteNum >= 0 && minuteNum <= 59
&& ['AM', 'PM'].includes(period)
}
const validateHourlyConfig = (config: any, t: any): string => {
const i18nPrefix = 'workflow.errorMsg'
if (!config.datetime)
return t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.triggerSchedule.startTime') })
const startTime = new Date(config.datetime)
if (Number.isNaN(startTime.getTime()))
return t('workflow.nodes.triggerSchedule.invalidStartTime')
if (startTime <= new Date())
return t('workflow.nodes.triggerSchedule.startTimeMustBeFuture')
const recurEvery = config.recur_every || 1
if (recurEvery < 1 || recurEvery > 999)
return t('workflow.nodes.triggerSchedule.invalidRecurEvery')
if (!config.recur_unit || !['hours', 'minutes'].includes(config.recur_unit))
return t('workflow.nodes.triggerSchedule.invalidRecurUnit')
return ''
}
const validateDailyConfig = (config: any, t: any): string => {
const i18nPrefix = 'workflow.errorMsg'
if (!config.time)
return t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.triggerSchedule.time') })
if (!isValidTimeFormat(config.time))
return t('workflow.nodes.triggerSchedule.invalidTimeFormat')
return ''
}
const validateWeeklyConfig = (config: any, t: any): string => {
const dailyError = validateDailyConfig(config, t)
if (dailyError) return dailyError
const i18nPrefix = 'workflow.errorMsg'
if (!config.weekdays || config.weekdays.length === 0)
return t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.triggerSchedule.weekdays') })
const validWeekdays = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']
for (const day of config.weekdays) {
if (!validWeekdays.includes(day))
return t('workflow.nodes.triggerSchedule.invalidWeekday', { weekday: day })
}
return ''
}
const validateMonthlyConfig = (config: any, t: any): string => {
const dailyError = validateDailyConfig(config, t)
if (dailyError) return dailyError
const i18nPrefix = 'workflow.errorMsg'
if (!config.monthly_day)
return t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.triggerSchedule.monthlyDay') })
if (config.monthly_day !== 'last'
&& (typeof config.monthly_day !== 'number'
|| config.monthly_day < 1
|| config.monthly_day > 31))
return t('workflow.nodes.triggerSchedule.invalidMonthlyDay')
return ''
}
const validateOnceConfig = (config: any, t: any): string => {
const i18nPrefix = 'workflow.errorMsg'
if (!config.datetime)
return t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.triggerSchedule.executionTime') })
const executionTime = new Date(config.datetime)
if (Number.isNaN(executionTime.getTime()))
return t('workflow.nodes.triggerSchedule.invalidExecutionTime')
if (executionTime <= new Date())
return t('workflow.nodes.triggerSchedule.executionTimeMustBeFuture')
return ''
}
const validateVisualConfig = (payload: ScheduleTriggerNodeType, t: any): string => {
const i18nPrefix = 'workflow.errorMsg'
const { visual_config } = payload
if (!visual_config)
return t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.triggerSchedule.visualConfig') })
switch (payload.frequency) {
case 'hourly':
return validateHourlyConfig(visual_config, t)
case 'daily':
return validateDailyConfig(visual_config, t)
case 'weekly':
return validateWeeklyConfig(visual_config, t)
case 'monthly':
return validateMonthlyConfig(visual_config, t)
case 'once':
return validateOnceConfig(visual_config, t)
default:
return t('workflow.nodes.triggerSchedule.invalidFrequency')
}
}
const nodeDefault: NodeDefault<ScheduleTriggerNodeType> = {
defaultValue: {
@ -24,10 +149,50 @@ const nodeDefault: NodeDefault<ScheduleTriggerNodeType> = {
: ALL_COMPLETION_AVAILABLE_BLOCKS.filter(type => type !== BlockEnum.End)
return nodes.filter(type => type !== BlockEnum.Start)
},
checkValid(_payload: ScheduleTriggerNodeType, _t: any) {
checkValid(payload: ScheduleTriggerNodeType, t: any) {
const i18nPrefix = 'workflow.errorMsg'
let errorMessages = ''
if (!errorMessages && !payload.mode)
errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.triggerSchedule.mode') })
if (!errorMessages && !payload.timezone)
errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.triggerSchedule.timezone') })
if (!errorMessages && payload.timezone) {
try {
Intl.DateTimeFormat(undefined, { timeZone: payload.timezone })
}
catch {
errorMessages = t('workflow.nodes.triggerSchedule.invalidTimezone')
}
}
if (!errorMessages) {
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))
errorMessages = t('workflow.nodes.triggerSchedule.invalidCronExpression')
}
else if (payload.mode === 'visual') {
if (!payload.frequency)
errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.triggerSchedule.frequency') })
else
errorMessages = validateVisualConfig(payload, t)
}
}
if (!errorMessages) {
try {
const nextTimes = getNextExecutionTimes(payload, 1)
if (nextTimes.length === 0)
errorMessages = t('workflow.nodes.triggerSchedule.noValidExecutionTime')
}
catch {
errorMessages = t('workflow.nodes.triggerSchedule.executionTimeCalculationError')
}
}
return {
isValid: true,
errorMessage: '',
isValid: !errorMessages,
errorMessage: errorMessages,
}
},
}

View File

@ -94,19 +94,28 @@ export const getNodesConnectedSourceOrTargetHandleIdsMap = (changes: ConnectedSo
}
export const getValidTreeNodes = (nodes: Node[], edges: Edge[]) => {
const startNode = nodes.find(node => node.data.type === BlockEnum.Start)
// Find all start nodes (Start and Trigger nodes)
const startNodes = nodes.filter(node =>
node.data.type === BlockEnum.Start
|| node.data.type === BlockEnum.TriggerSchedule
|| node.data.type === BlockEnum.TriggerWebhook
|| node.data.type === BlockEnum.TriggerPlugin,
)
if (!startNode) {
if (startNodes.length === 0) {
return {
validNodes: [],
maxDepth: 0,
}
}
const list: Node[] = [startNode]
let maxDepth = 1
const list: Node[] = []
let maxDepth = 0
const traverse = (root: Node, depth: number) => {
// Add the current node to the list
list.push(root)
if (depth > maxDepth)
maxDepth = depth
@ -114,19 +123,19 @@ export const getValidTreeNodes = (nodes: Node[], edges: Edge[]) => {
if (outgoers.length) {
outgoers.forEach((outgoer) => {
list.push(outgoer)
// Only traverse if we haven't processed this node yet (avoid cycles)
if (!list.find(n => n.id === outgoer.id)) {
if (outgoer.data.type === BlockEnum.Iteration)
list.push(...nodes.filter(node => node.parentId === outgoer.id))
if (outgoer.data.type === BlockEnum.Loop)
list.push(...nodes.filter(node => node.parentId === outgoer.id))
if (outgoer.data.type === BlockEnum.Iteration)
list.push(...nodes.filter(node => node.parentId === outgoer.id))
if (outgoer.data.type === BlockEnum.Loop)
list.push(...nodes.filter(node => node.parentId === outgoer.id))
traverse(outgoer, depth + 1)
traverse(outgoer, depth + 1)
}
})
}
else {
list.push(root)
else {
// Leaf node - add iteration/loop children if any
if (root.data.type === BlockEnum.Iteration)
list.push(...nodes.filter(node => node.parentId === root.id))
if (root.data.type === BlockEnum.Loop)
@ -134,7 +143,11 @@ export const getValidTreeNodes = (nodes: Node[], edges: Edge[]) => {
}
}
traverse(startNode, maxDepth)
// Start traversal from all start nodes
startNodes.forEach((startNode) => {
if (!list.find(n => n.id === startNode.id))
traverse(startNode, 1)
})
return {
validNodes: uniqBy(list, 'id'),
@ -196,7 +209,12 @@ export const getParallelInfo = (nodes: Node[], edges: Edge[], parentNodeId?: str
startNode = nodes.find(node => node.id === (parentNode.data as (IterationNodeType | LoopNodeType)).start_node_id)
}
else {
startNode = nodes.find(node => node.data.type === BlockEnum.Start)
startNode = nodes.find(node =>
node.data.type === BlockEnum.Start
|| node.data.type === BlockEnum.TriggerSchedule
|| node.data.type === BlockEnum.TriggerWebhook
|| node.data.type === BlockEnum.TriggerPlugin,
)
}
if (!startNode)
throw new Error('Start node not found')

View File

@ -45,6 +45,7 @@ const translation = {
needConnectTip: 'This step is not connected to anything',
maxTreeDepth: 'Maximum limit of {{depth}} nodes per branch',
needEndNode: 'The End node must be added',
needStartNode: 'A start node (Start or Trigger) must be added',
needAnswerNode: 'The Answer node must be added',
workflowProcess: 'Workflow Process',
notRunning: 'Not running yet',
@ -953,6 +954,26 @@ const translation = {
days: 'Days',
lastDay: 'Last day',
lastDayTooltip: 'Not all months have 31 days. Use the \'last day\' option to select each month\'s final day.',
mode: 'Mode',
timezone: 'Timezone',
visualConfig: 'Visual Configuration',
monthlyDay: 'Monthly Day',
executionTime: 'Execution Time',
weekdays: 'Weekdays',
invalidTimezone: 'Invalid timezone',
invalidCronExpression: 'Invalid cron expression',
noValidExecutionTime: 'No valid execution time can be calculated',
executionTimeCalculationError: 'Failed to calculate execution times',
invalidFrequency: 'Invalid frequency',
invalidStartTime: 'Invalid start time',
startTimeMustBeFuture: 'Start time must be in the future',
invalidRecurEvery: 'Recur every must be between 1 and 999',
invalidRecurUnit: 'Invalid recur unit',
invalidTimeFormat: 'Invalid time format (expected HH:MM AM/PM)',
invalidWeekday: 'Invalid weekday: {{weekday}}',
invalidMonthlyDay: 'Monthly day must be between 1-31 or "last"',
invalidExecutionTime: 'Invalid execution time',
executionTimeMustBeFuture: 'Execution time must be in the future',
},
triggerWebhook: {
title: 'Webhook Trigger',

View File

@ -45,6 +45,7 @@ const translation = {
needConnectTip: '接続されていないステップがあります',
maxTreeDepth: '1 ブランチあたりの最大ノード数:{{depth}}',
needEndNode: '終了ブロックを追加する必要があります',
needStartNode: '開始ノード(スタートまたはトリガー)を追加する必要があります',
needAnswerNode: '回答ブロックを追加する必要があります',
workflowProcess: 'ワークフロー処理',
notRunning: 'まだ実行されていません',
@ -953,6 +954,26 @@ const translation = {
lastDayTooltip: 'すべての月に31日があるわけではありません。「月末」オプションを使用して各月の最終日を選択してください。',
useVisualPicker: 'ビジュアル設定を使用',
nodeTitle: 'スケジュールトリガー',
mode: 'モード',
timezone: 'タイムゾーン',
visualConfig: 'ビジュアル設定',
monthlyDay: '月の日',
executionTime: '実行時間',
weekdays: '曜日',
invalidTimezone: '無効なタイムゾーン',
invalidCronExpression: '無効なCron式',
noValidExecutionTime: '有効な実行時間を計算できません',
executionTimeCalculationError: '実行時間の計算に失敗しました',
invalidFrequency: '無効な頻度',
invalidStartTime: '無効な開始時間',
startTimeMustBeFuture: '開始時間は未来の時間である必要があります',
invalidRecurEvery: '繰り返し間隔は1から999の間である必要があります',
invalidRecurUnit: '無効な繰り返し単位',
invalidTimeFormat: '無効な時間形式期待される形式HH:MM AM/PM',
invalidWeekday: '無効な曜日:{{weekday}}',
invalidMonthlyDay: '月の日は1-31の間または"last"である必要があります',
invalidExecutionTime: '無効な実行時間',
executionTimeMustBeFuture: '実行時間は未来の時間である必要があります',
},
triggerWebhook: {
title: 'Webhook トリガー',

View File

@ -44,6 +44,7 @@ const translation = {
needConnectTip: '此节点尚未连接到其他节点',
maxTreeDepth: '每个分支最大限制 {{depth}} 个节点',
needEndNode: '必须添加结束节点',
needStartNode: '必须添加开始节点(开始或触发器)',
needAnswerNode: '必须添加直接回复节点',
workflowProcess: '工作流',
notRunning: '尚未运行',
@ -953,6 +954,26 @@ const translation = {
useVisualPicker: '使用可视化配置',
days: '天',
notConfigured: '未配置',
mode: '模式',
timezone: '时区',
visualConfig: '可视化配置',
monthlyDay: '月份日期',
executionTime: '执行时间',
weekdays: '工作日',
invalidTimezone: '无效的时区',
invalidCronExpression: '无效的 Cron 表达式',
noValidExecutionTime: '无法计算有效的执行时间',
executionTimeCalculationError: '执行时间计算失败',
invalidFrequency: '无效的频率',
invalidStartTime: '无效的开始时间',
startTimeMustBeFuture: '开始时间必须是将来的时间',
invalidRecurEvery: '重复间隔必须在 1 到 999 之间',
invalidRecurUnit: '无效的重复单位',
invalidTimeFormat: '无效的时间格式预期格式HH:MM AM/PM',
invalidWeekday: '无效的工作日:{{weekday}}',
invalidMonthlyDay: '月份日期必须在 1-31 之间或为"last"',
invalidExecutionTime: '无效的执行时间',
executionTimeMustBeFuture: '执行时间必须是将来的时间',
},
triggerWebhook: {
configPlaceholder: 'Webhook 触发器配置将在此处实现',