diff --git a/web/app/components/workflow/hooks/use-checklist.ts b/web/app/components/workflow/hooks/use-checklist.ts index 47ebaa6710..335662a322 100644 --- a/web/app/components/workflow/hooks/use-checklist.ts +++ b/web/app/components/workflow/hooks/use-checklist.ts @@ -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 diff --git a/web/app/components/workflow/nodes/trigger-schedule/__tests__/default.test.ts b/web/app/components/workflow/nodes/trigger-schedule/__tests__/default.test.ts new file mode 100644 index 0000000000..02627268ac --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/__tests__/default.test.ts @@ -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') + }) + }) +}) diff --git a/web/app/components/workflow/nodes/trigger-schedule/default.ts b/web/app/components/workflow/nodes/trigger-schedule/default.ts index 3a9ead48a9..fc8daaab58 100644 --- a/web/app/components/workflow/nodes/trigger-schedule/default.ts +++ b/web/app/components/workflow/nodes/trigger-schedule/default.ts @@ -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 = { defaultValue: { @@ -24,10 +149,50 @@ const nodeDefault: NodeDefault = { : 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, } }, } diff --git a/web/app/components/workflow/utils/workflow.ts b/web/app/components/workflow/utils/workflow.ts index 93c0d38d4b..5f95013673 100644 --- a/web/app/components/workflow/utils/workflow.ts +++ b/web/app/components/workflow/utils/workflow.ts @@ -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') diff --git a/web/i18n/en-US/workflow.ts b/web/i18n/en-US/workflow.ts index 5b7b79a310..2f055bfb2d 100644 --- a/web/i18n/en-US/workflow.ts +++ b/web/i18n/en-US/workflow.ts @@ -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', diff --git a/web/i18n/ja-JP/workflow.ts b/web/i18n/ja-JP/workflow.ts index cbeb8d0643..c3b37f7ee3 100644 --- a/web/i18n/ja-JP/workflow.ts +++ b/web/i18n/ja-JP/workflow.ts @@ -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 トリガー', diff --git a/web/i18n/zh-Hans/workflow.ts b/web/i18n/zh-Hans/workflow.ts index 08bf2498f9..972d3ef343 100644 --- a/web/i18n/zh-Hans/workflow.ts +++ b/web/i18n/zh-Hans/workflow.ts @@ -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 触发器配置将在此处实现',