From ae25f90f340a5748f54d85cbfaaf341b658ce58a Mon Sep 17 00:00:00 2001 From: lyzno1 <92089059+lyzno1@users.noreply.github.com> Date: Sat, 16 Aug 2025 19:25:18 +0800 Subject: [PATCH 001/180] Replace export button with more actions button in workflow control panel (#24033) --- .../components/workflow/operator/control.tsx | 4 +- .../{export-image.tsx => more-actions.tsx} | 43 ++++++++----------- web/i18n/de-DE/workflow.ts | 1 + web/i18n/en-US/workflow.ts | 1 + web/i18n/es-ES/workflow.ts | 1 + web/i18n/fa-IR/workflow.ts | 1 + web/i18n/fr-FR/workflow.ts | 1 + web/i18n/hi-IN/workflow.ts | 1 + web/i18n/it-IT/workflow.ts | 1 + web/i18n/ja-JP/workflow.ts | 1 + web/i18n/ko-KR/workflow.ts | 1 + web/i18n/pl-PL/workflow.ts | 1 + web/i18n/pt-BR/workflow.ts | 1 + web/i18n/ro-RO/workflow.ts | 1 + web/i18n/ru-RU/workflow.ts | 1 + web/i18n/sl-SI/workflow.ts | 1 + web/i18n/th-TH/workflow.ts | 1 + web/i18n/tr-TR/workflow.ts | 1 + web/i18n/uk-UA/workflow.ts | 1 + web/i18n/vi-VN/workflow.ts | 1 + web/i18n/zh-Hans/workflow.ts | 1 + web/i18n/zh-Hant/workflow.ts | 1 + 22 files changed, 39 insertions(+), 28 deletions(-) rename web/app/components/workflow/operator/{export-image.tsx => more-actions.tsx} (87%) diff --git a/web/app/components/workflow/operator/control.tsx b/web/app/components/workflow/operator/control.tsx index 7967bf0a6c..1bfb4a57b4 100644 --- a/web/app/components/workflow/operator/control.tsx +++ b/web/app/components/workflow/operator/control.tsx @@ -24,7 +24,7 @@ import { useStore } from '../store' import Divider from '../../base/divider' import AddBlock from './add-block' import TipPopup from './tip-popup' -import ExportImage from './export-image' +import MoreActions from './more-actions' import { useOperator } from './hooks' import cn from '@/utils/classnames' @@ -89,7 +89,6 @@ const Control = () => { -
{ {!maximizeCanvas && }
+ ) } diff --git a/web/app/components/workflow/operator/export-image.tsx b/web/app/components/workflow/operator/more-actions.tsx similarity index 87% rename from web/app/components/workflow/operator/export-image.tsx rename to web/app/components/workflow/operator/more-actions.tsx index 546c702d64..dfb1081238 100644 --- a/web/app/components/workflow/operator/export-image.tsx +++ b/web/app/components/workflow/operator/more-actions.tsx @@ -5,10 +5,10 @@ import { useState, } from 'react' import { useTranslation } from 'react-i18next' +import { RiExportLine, RiMoreFill } from '@remixicon/react' import { toJpeg, toPng, toSvg } from 'html-to-image' import { useNodesReadOnly } from '../hooks' import TipPopup from './tip-popup' -import { RiExportLine } from '@remixicon/react' import cn from '@/utils/classnames' import { useStore as useAppStore } from '@/app/components/app/store' import { @@ -19,7 +19,7 @@ import { import { getNodesBounds, useReactFlow } from 'reactflow' import ImagePreview from '@/app/components/base/image-uploader/image-preview' -const ExportImage: FC = () => { +const MoreActions: FC = () => { const { t } = useTranslation() const { getNodesReadOnly } = useNodesReadOnly() const reactFlow = useReactFlow() @@ -52,14 +52,11 @@ const ExportImage: FC = () => { let filename = `${appDetail.name}` if (currentWorkflow) { - // Get all nodes and their bounds const nodes = reactFlow.getNodes() const nodesBounds = getNodesBounds(nodes) - // Save current viewport const currentViewport = reactFlow.getViewport() - // Calculate the required zoom to fit all nodes const viewportWidth = window.innerWidth const viewportHeight = window.innerHeight const zoom = Math.min( @@ -68,30 +65,25 @@ const ExportImage: FC = () => { 1, ) - // Calculate center position const centerX = nodesBounds.x + nodesBounds.width / 2 const centerY = nodesBounds.y + nodesBounds.height / 2 - // Set viewport to show all nodes reactFlow.setViewport({ x: viewportWidth / 2 - centerX * zoom, y: viewportHeight / 2 - centerY * zoom, zoom, }) - // Wait for the transition to complete await new Promise(resolve => setTimeout(resolve, 300)) - // Calculate actual content size with padding - const padding = 50 // More padding for better visualization + const padding = 50 const contentWidth = nodesBounds.width + padding * 2 const contentHeight = nodesBounds.height + padding * 2 - // Export with higher quality for whole workflow const exportOptions = { filter, - backgroundColor: '#1a1a1a', // Dark background to match previous style - pixelRatio: 2, // Higher resolution for better zoom + backgroundColor: '#1a1a1a', + pixelRatio: 2, width: contentWidth, height: contentHeight, style: { @@ -117,13 +109,11 @@ const ExportImage: FC = () => { filename += '-whole-workflow' - // Restore original viewport after a delay setTimeout(() => { reactFlow.setViewport(currentViewport) }, 500) } - else { - // Current viewport export (existing functionality) + else { switch (type) { case 'png': dataUrl = await toPng(flowElement, { filter }) @@ -140,11 +130,9 @@ const ExportImage: FC = () => { } if (currentWorkflow) { - // For whole workflow, show preview first setPreviewUrl(dataUrl) setPreviewTitle(`${filename}.${type}`) - // Also auto-download const link = document.createElement('a') link.href = dataUrl link.download = `${filename}.${type}` @@ -152,8 +140,7 @@ const ExportImage: FC = () => { link.click() document.body.removeChild(link) } - else { - // For current view, just download + else { const link = document.createElement('a') link.href = dataUrl link.download = `${filename}.${type}` @@ -179,14 +166,14 @@ const ExportImage: FC = () => { - +
{ )} onClick={handleTrigger} > - +
+
+ + {t('workflow.common.exportImage')} +
{t('workflow.common.currentView')}
@@ -262,4 +253,4 @@ const ExportImage: FC = () => { ) } -export default memo(ExportImage) +export default memo(MoreActions) diff --git a/web/i18n/de-DE/workflow.ts b/web/i18n/de-DE/workflow.ts index ae62911378..7684a763a3 100644 --- a/web/i18n/de-DE/workflow.ts +++ b/web/i18n/de-DE/workflow.ts @@ -116,6 +116,7 @@ const translation = { tagBound: 'Anzahl der Apps, die dieses Tag verwenden', currentWorkflow: 'Aktueller Arbeitsablauf', currentView: 'Aktuelle Ansicht', + moreActions: 'Weitere Aktionen', }, env: { envPanelTitle: 'Umgebungsvariablen', diff --git a/web/i18n/en-US/workflow.ts b/web/i18n/en-US/workflow.ts index d1229ec148..306dec0f09 100644 --- a/web/i18n/en-US/workflow.ts +++ b/web/i18n/en-US/workflow.ts @@ -76,6 +76,7 @@ const translation = { exportSVG: 'Export as SVG', currentView: 'Current View', currentWorkflow: 'Current Workflow', + moreActions: 'More Actions', model: 'Model', workflowAsTool: 'Workflow as Tool', configureRequired: 'Configure Required', diff --git a/web/i18n/es-ES/workflow.ts b/web/i18n/es-ES/workflow.ts index 054115935d..3d7e2fdafc 100644 --- a/web/i18n/es-ES/workflow.ts +++ b/web/i18n/es-ES/workflow.ts @@ -116,6 +116,7 @@ const translation = { tagBound: 'Número de aplicaciones que utilizan esta etiqueta', currentView: 'Vista actual', currentWorkflow: 'Flujo de trabajo actual', + moreActions: 'Más acciones', }, env: { envPanelTitle: 'Variables de Entorno', diff --git a/web/i18n/fa-IR/workflow.ts b/web/i18n/fa-IR/workflow.ts index 96f11d9369..6264841397 100644 --- a/web/i18n/fa-IR/workflow.ts +++ b/web/i18n/fa-IR/workflow.ts @@ -116,6 +116,7 @@ const translation = { tagBound: 'تعداد برنامه‌هایی که از این برچسب استفاده می‌کنند', currentView: 'نمای فعلی', currentWorkflow: 'گردش کار فعلی', + moreActions: 'اقدامات بیشتر', }, env: { envPanelTitle: 'متغیرهای محیطی', diff --git a/web/i18n/fr-FR/workflow.ts b/web/i18n/fr-FR/workflow.ts index 36aac55977..0e9f57b56f 100644 --- a/web/i18n/fr-FR/workflow.ts +++ b/web/i18n/fr-FR/workflow.ts @@ -116,6 +116,7 @@ const translation = { tagBound: 'Nombre d\'applications utilisant cette étiquette', currentView: 'Vue actuelle', currentWorkflow: 'Flux de travail actuel', + moreActions: 'Plus d’actions', }, env: { envPanelTitle: 'Variables d\'Environnement', diff --git a/web/i18n/hi-IN/workflow.ts b/web/i18n/hi-IN/workflow.ts index bd1a6a306a..13dbc5a912 100644 --- a/web/i18n/hi-IN/workflow.ts +++ b/web/i18n/hi-IN/workflow.ts @@ -119,6 +119,7 @@ const translation = { tagBound: 'इस टैग का उपयोग करने वाले ऐप्स की संख्या', currentView: 'वर्तमान दृश्य', currentWorkflow: 'वर्तमान कार्यप्रवाह', + moreActions: 'अधिक क्रियाएँ', }, env: { envPanelTitle: 'पर्यावरण चर', diff --git a/web/i18n/it-IT/workflow.ts b/web/i18n/it-IT/workflow.ts index 33d52a7c4a..b342f0b0a8 100644 --- a/web/i18n/it-IT/workflow.ts +++ b/web/i18n/it-IT/workflow.ts @@ -120,6 +120,7 @@ const translation = { tagBound: 'Numero di app che utilizzano questo tag', currentWorkflow: 'Flusso di lavoro corrente', currentView: 'Vista corrente', + moreActions: 'Altre azioni', }, env: { envPanelTitle: 'Variabili d\'Ambiente', diff --git a/web/i18n/ja-JP/workflow.ts b/web/i18n/ja-JP/workflow.ts index 2c88b54b8a..307dc71ec0 100644 --- a/web/i18n/ja-JP/workflow.ts +++ b/web/i18n/ja-JP/workflow.ts @@ -116,6 +116,7 @@ const translation = { loadMore: 'さらに読み込む', noHistory: '履歴がありません', tagBound: 'このタグを使用しているアプリの数', + moreActions: 'さらにアクション', }, env: { envPanelTitle: '環境変数', diff --git a/web/i18n/ko-KR/workflow.ts b/web/i18n/ko-KR/workflow.ts index 432ae14aed..49ca6c6cab 100644 --- a/web/i18n/ko-KR/workflow.ts +++ b/web/i18n/ko-KR/workflow.ts @@ -120,6 +120,7 @@ const translation = { tagBound: '이 태그를 사용하는 앱 수', currentView: '현재 보기', currentWorkflow: '현재 워크플로', + moreActions: '더 많은 작업', }, env: { envPanelTitle: '환경 변수', diff --git a/web/i18n/pl-PL/workflow.ts b/web/i18n/pl-PL/workflow.ts index 69de94091f..cea026b6c4 100644 --- a/web/i18n/pl-PL/workflow.ts +++ b/web/i18n/pl-PL/workflow.ts @@ -116,6 +116,7 @@ const translation = { tagBound: 'Liczba aplikacji korzystających z tego tagu', currentWorkflow: 'Bieżący przepływ pracy', currentView: 'Bieżący widok', + moreActions: 'Więcej akcji', }, env: { envPanelTitle: 'Zmienne Środowiskowe', diff --git a/web/i18n/pt-BR/workflow.ts b/web/i18n/pt-BR/workflow.ts index 6bddef6568..3c85c37301 100644 --- a/web/i18n/pt-BR/workflow.ts +++ b/web/i18n/pt-BR/workflow.ts @@ -116,6 +116,7 @@ const translation = { tagBound: 'Número de aplicativos usando esta tag', currentView: 'Visualização atual', currentWorkflow: 'Fluxo de trabalho atual', + moreActions: 'Mais ações', }, env: { envPanelTitle: 'Variáveis de Ambiente', diff --git a/web/i18n/ro-RO/workflow.ts b/web/i18n/ro-RO/workflow.ts index 58118cdb04..0caec4993c 100644 --- a/web/i18n/ro-RO/workflow.ts +++ b/web/i18n/ro-RO/workflow.ts @@ -116,6 +116,7 @@ const translation = { tagBound: 'Numărul de aplicații care folosesc acest tag', currentView: 'Vizualizare curentă', currentWorkflow: 'Flux de lucru curent', + moreActions: 'Mai multe acțiuni', }, env: { envPanelTitle: 'Variabile de Mediu', diff --git a/web/i18n/ru-RU/workflow.ts b/web/i18n/ru-RU/workflow.ts index 91890e5254..d4c9b81b39 100644 --- a/web/i18n/ru-RU/workflow.ts +++ b/web/i18n/ru-RU/workflow.ts @@ -116,6 +116,7 @@ const translation = { tagBound: 'Количество приложений, использующих этот тег', currentView: 'Текущий вид', currentWorkflow: 'Текущий рабочий процесс', + moreActions: 'Больше действий', }, env: { envPanelTitle: 'Переменные среды', diff --git a/web/i18n/sl-SI/workflow.ts b/web/i18n/sl-SI/workflow.ts index f6676f673d..2df5271043 100644 --- a/web/i18n/sl-SI/workflow.ts +++ b/web/i18n/sl-SI/workflow.ts @@ -116,6 +116,7 @@ const translation = { tagBound: 'Število aplikacij, ki uporabljajo to oznako', currentView: 'Trenutni pogled', currentWorkflow: 'Trenutni potek dela', + moreActions: 'Več dejanj', }, env: { modal: { diff --git a/web/i18n/th-TH/workflow.ts b/web/i18n/th-TH/workflow.ts index 38841377ad..eea8e1e300 100644 --- a/web/i18n/th-TH/workflow.ts +++ b/web/i18n/th-TH/workflow.ts @@ -116,6 +116,7 @@ const translation = { tagBound: 'จำนวนแอปพลิเคชันที่ใช้แท็กนี้', currentWorkflow: 'เวิร์กโฟลว์ปัจจุบัน', currentView: 'ปัจจุบัน View', + moreActions: 'การดําเนินการเพิ่มเติม', }, env: { envPanelTitle: 'ตัวแปรสภาพแวดล้อม', diff --git a/web/i18n/tr-TR/workflow.ts b/web/i18n/tr-TR/workflow.ts index 1830f8895a..0a94550eaa 100644 --- a/web/i18n/tr-TR/workflow.ts +++ b/web/i18n/tr-TR/workflow.ts @@ -116,6 +116,7 @@ const translation = { tagBound: 'Bu etiketi kullanan uygulama sayısı', currentView: 'Geçerli Görünüm', currentWorkflow: 'Mevcut İş Akışı', + moreActions: 'Daha Fazla Eylem', }, env: { envPanelTitle: 'Çevre Değişkenleri', diff --git a/web/i18n/uk-UA/workflow.ts b/web/i18n/uk-UA/workflow.ts index 1f9031e6fb..1df2f444fd 100644 --- a/web/i18n/uk-UA/workflow.ts +++ b/web/i18n/uk-UA/workflow.ts @@ -116,6 +116,7 @@ const translation = { tagBound: 'Кількість додатків, що використовують цей тег', currentView: 'Поточний вигляд', currentWorkflow: 'Поточний робочий процес', + moreActions: 'Більше дій', }, env: { envPanelTitle: 'Змінні середовища', diff --git a/web/i18n/vi-VN/workflow.ts b/web/i18n/vi-VN/workflow.ts index 0cff7e1c5d..23482ee06d 100644 --- a/web/i18n/vi-VN/workflow.ts +++ b/web/i18n/vi-VN/workflow.ts @@ -116,6 +116,7 @@ const translation = { tagBound: 'Số lượng ứng dụng sử dụng thẻ này', currentWorkflow: 'Quy trình làm việc hiện tại', currentView: 'Hiện tại View', + moreActions: 'Hành động khác', }, env: { envPanelTitle: 'Biến Môi Trường', diff --git a/web/i18n/zh-Hans/workflow.ts b/web/i18n/zh-Hans/workflow.ts index 018404805d..cef27f5b9d 100644 --- a/web/i18n/zh-Hans/workflow.ts +++ b/web/i18n/zh-Hans/workflow.ts @@ -75,6 +75,7 @@ const translation = { exportSVG: '导出为 SVG', currentView: '当前视图', currentWorkflow: '整个工作流', + moreActions: '更多操作', model: '模型', workflowAsTool: '发布为工具', configureRequired: '需要进行配置', diff --git a/web/i18n/zh-Hant/workflow.ts b/web/i18n/zh-Hant/workflow.ts index 9c6a9e77ff..e40221f2bc 100644 --- a/web/i18n/zh-Hant/workflow.ts +++ b/web/i18n/zh-Hant/workflow.ts @@ -116,6 +116,7 @@ const translation = { tagBound: '使用此標籤的應用程式數量', currentView: '當前檢視', currentWorkflow: '當前工作流程', + moreActions: '更多動作', }, env: { envPanelTitle: '環境變數', From f214eeb7b1e0782723baf7274aaf69cc370f8040 Mon Sep 17 00:00:00 2001 From: lyzno1 <92089059+lyzno1@users.noreply.github.com> Date: Sat, 16 Aug 2025 19:26:44 +0800 Subject: [PATCH 002/180] feat: add scroll to selected node button in workflow header (#24030) Co-authored-by: zhangxuhe1 --- .../workflow/header/header-in-normal.tsx | 8 ++- .../header/scroll-to-selected-node-button.tsx | 34 ++++++++++ .../nodes/_base/components/node-position.tsx | 63 ------------------- .../_base/components/workflow-panel/index.tsx | 2 - web/i18n/de-DE/workflow.ts | 2 +- web/i18n/en-US/workflow.ts | 2 +- web/i18n/es-ES/workflow.ts | 2 +- web/i18n/fa-IR/workflow.ts | 2 +- web/i18n/fr-FR/workflow.ts | 2 +- web/i18n/hi-IN/workflow.ts | 2 +- web/i18n/it-IT/workflow.ts | 2 +- web/i18n/ja-JP/workflow.ts | 2 +- web/i18n/ko-KR/workflow.ts | 2 +- web/i18n/pl-PL/workflow.ts | 2 +- web/i18n/pt-BR/workflow.ts | 2 +- web/i18n/ro-RO/workflow.ts | 2 +- web/i18n/ru-RU/workflow.ts | 2 +- web/i18n/sl-SI/workflow.ts | 2 +- web/i18n/th-TH/workflow.ts | 2 +- web/i18n/tr-TR/workflow.ts | 2 +- web/i18n/uk-UA/workflow.ts | 2 +- web/i18n/vi-VN/workflow.ts | 2 +- web/i18n/zh-Hans/workflow.ts | 2 +- web/i18n/zh-Hant/workflow.ts | 2 +- 24 files changed, 60 insertions(+), 87 deletions(-) create mode 100644 web/app/components/workflow/header/scroll-to-selected-node-button.tsx delete mode 100644 web/app/components/workflow/nodes/_base/components/node-position.tsx diff --git a/web/app/components/workflow/header/header-in-normal.tsx b/web/app/components/workflow/header/header-in-normal.tsx index 79a6509a7a..4093ffb262 100644 --- a/web/app/components/workflow/header/header-in-normal.tsx +++ b/web/app/components/workflow/header/header-in-normal.tsx @@ -17,6 +17,7 @@ import RunAndHistory from './run-and-history' import EditingTitle from './editing-title' import EnvButton from './env-button' import VersionHistoryButton from './version-history-button' +import ScrollToSelectedNodeButton from './scroll-to-selected-node-button' export type HeaderInNormalProps = { components?: { @@ -53,10 +54,13 @@ const HeaderInNormal = ({ }, [workflowStore, handleBackupDraft, selectedNode, handleNodeSelect, setShowWorkflowVersionHistoryPanel, setShowEnvPanel, setShowDebugAndPreviewPanel, setShowVariableInspectPanel, setShowChatVariablePanel]) return ( - <> +
+
+ +
{components?.left} @@ -65,7 +69,7 @@ const HeaderInNormal = ({ {components?.middle}
- +
) } diff --git a/web/app/components/workflow/header/scroll-to-selected-node-button.tsx b/web/app/components/workflow/header/scroll-to-selected-node-button.tsx new file mode 100644 index 0000000000..d3e7248d9a --- /dev/null +++ b/web/app/components/workflow/header/scroll-to-selected-node-button.tsx @@ -0,0 +1,34 @@ +import type { FC } from 'react' +import { useCallback } from 'react' +import { useNodes } from 'reactflow' +import { useTranslation } from 'react-i18next' +import type { CommonNodeType } from '../types' +import { scrollToWorkflowNode } from '../utils/node-navigation' +import cn from '@/utils/classnames' + +const ScrollToSelectedNodeButton: FC = () => { + const { t } = useTranslation() + const nodes = useNodes() + const selectedNode = nodes.find(node => node.data.selected) + + const handleScrollToSelectedNode = useCallback(() => { + if (!selectedNode) return + scrollToWorkflowNode(selectedNode.id) + }, [selectedNode]) + + if (!selectedNode) + return null + + return ( +
+ {t('workflow.panel.scrollToSelectedNode')} +
+ ) +} + +export default ScrollToSelectedNodeButton diff --git a/web/app/components/workflow/nodes/_base/components/node-position.tsx b/web/app/components/workflow/nodes/_base/components/node-position.tsx deleted file mode 100644 index e844726b4f..0000000000 --- a/web/app/components/workflow/nodes/_base/components/node-position.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { memo } from 'react' -import { useTranslation } from 'react-i18next' -import { useShallow } from 'zustand/react/shallow' -import { RiCrosshairLine } from '@remixicon/react' -import { useReactFlow, useStore } from 'reactflow' -import TooltipPlus from '@/app/components/base/tooltip' -import { useNodesSyncDraft } from '@/app/components/workflow-app/hooks' - -type NodePositionProps = { - nodeId: string -} -const NodePosition = ({ - nodeId, -}: NodePositionProps) => { - const { t } = useTranslation() - const reactflow = useReactFlow() - const { doSyncWorkflowDraft } = useNodesSyncDraft() - const { - nodePosition, - nodeWidth, - nodeHeight, - } = useStore(useShallow((s) => { - const nodes = s.getNodes() - const currentNode = nodes.find(node => node.id === nodeId)! - - return { - nodePosition: currentNode.position, - nodeWidth: currentNode.width, - nodeHeight: currentNode.height, - } - })) - const transform = useStore(s => s.transform) - - if (!nodePosition || !nodeWidth || !nodeHeight) return null - - const workflowContainer = document.getElementById('workflow-container') - const zoom = transform[2] - - const { clientWidth, clientHeight } = workflowContainer! - const { setViewport } = reactflow - - return ( - -
{ - setViewport({ - x: (clientWidth - 400 - nodeWidth * zoom) / 2 - nodePosition.x * zoom, - y: (clientHeight - nodeHeight * zoom) / 2 - nodePosition.y * zoom, - zoom: transform[2], - }) - doSyncWorkflowDraft() - }} - > - -
-
- ) -} - -export default memo(NodePosition) diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx index 4723b2dce7..c5670db9c9 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx @@ -19,7 +19,6 @@ import { useShallow } from 'zustand/react/shallow' import { useTranslation } from 'react-i18next' import NextStep from '../next-step' import PanelOperator from '../panel-operator' -import NodePosition from '@/app/components/workflow/nodes/_base/components/node-position' import HelpLink from '../help-link' import { DescriptionInput, @@ -362,7 +361,6 @@ const BasePanel: FC = ({ ) } -
diff --git a/web/i18n/de-DE/workflow.ts b/web/i18n/de-DE/workflow.ts index 7684a763a3..d40c640df8 100644 --- a/web/i18n/de-DE/workflow.ts +++ b/web/i18n/de-DE/workflow.ts @@ -315,13 +315,13 @@ const translation = { checklistResolved: 'Alle Probleme wurden gelöst', change: 'Ändern', optional: '(optional)', - moveToThisNode: 'Bewege zu diesem Knoten', selectNextStep: 'Nächsten Schritt auswählen', addNextStep: 'Fügen Sie den nächsten Schritt in diesem Arbeitsablauf hinzu.', organizeBlocks: 'Knoten organisieren', changeBlock: 'Knoten ändern', maximize: 'Maximiere die Leinwand', minimize: 'Vollbildmodus beenden', + scrollToSelectedNode: 'Zum ausgewählten Knoten scrollen', }, nodes: { common: { diff --git a/web/i18n/en-US/workflow.ts b/web/i18n/en-US/workflow.ts index 306dec0f09..91a1ff3ba4 100644 --- a/web/i18n/en-US/workflow.ts +++ b/web/i18n/en-US/workflow.ts @@ -320,7 +320,6 @@ const translation = { addNextStep: 'Add the next step in this workflow', selectNextStep: 'Select Next Step', runThisStep: 'Run this step', - moveToThisNode: 'Move to this node', checklist: 'Checklist', checklistTip: 'Make sure all issues are resolved before publishing', checklistResolved: 'All issues are resolved', @@ -329,6 +328,7 @@ const translation = { optional: '(optional)', maximize: 'Maximize Canvas', minimize: 'Exit Full Screen', + scrollToSelectedNode: 'Scroll to selected node', }, nodes: { common: { diff --git a/web/i18n/es-ES/workflow.ts b/web/i18n/es-ES/workflow.ts index 3d7e2fdafc..aff794f1f6 100644 --- a/web/i18n/es-ES/workflow.ts +++ b/web/i18n/es-ES/workflow.ts @@ -315,13 +315,13 @@ const translation = { checklistResolved: 'Se resolvieron todos los problemas', change: 'Cambiar', optional: '(opcional)', - moveToThisNode: 'Mueve a este nodo', organizeBlocks: 'Organizar nodos', addNextStep: 'Agrega el siguiente paso en este flujo de trabajo', changeBlock: 'Cambiar Nodo', selectNextStep: 'Seleccionar siguiente paso', maximize: 'Maximizar Canvas', minimize: 'Salir de pantalla completa', + scrollToSelectedNode: 'Desplácese hasta el nodo seleccionado', }, nodes: { common: { diff --git a/web/i18n/fa-IR/workflow.ts b/web/i18n/fa-IR/workflow.ts index 6264841397..ee3ce148cf 100644 --- a/web/i18n/fa-IR/workflow.ts +++ b/web/i18n/fa-IR/workflow.ts @@ -315,13 +315,13 @@ const translation = { checklistResolved: 'تمام مسائل حل شده‌اند', change: 'تغییر', optional: '(اختیاری)', - moveToThisNode: 'به این گره بروید', selectNextStep: 'گام بعدی را انتخاب کنید', changeBlock: 'تغییر گره', organizeBlocks: 'گره‌ها را سازماندهی کنید', addNextStep: 'مرحله بعدی را به این فرآیند اضافه کنید', minimize: 'خروج از حالت تمام صفحه', maximize: 'بیشینه‌سازی بوم', + scrollToSelectedNode: 'به گره انتخاب شده بروید', }, nodes: { common: { diff --git a/web/i18n/fr-FR/workflow.ts b/web/i18n/fr-FR/workflow.ts index 0e9f57b56f..8022768d44 100644 --- a/web/i18n/fr-FR/workflow.ts +++ b/web/i18n/fr-FR/workflow.ts @@ -315,13 +315,13 @@ const translation = { checklistResolved: 'Tous les problèmes ont été résolus', change: 'Modifier', optional: '(facultatif)', - moveToThisNode: 'Déplacer vers ce nœud', organizeBlocks: 'Organiser les nœuds', addNextStep: 'Ajoutez la prochaine étape dans ce flux de travail', selectNextStep: 'Sélectionner la prochaine étape', changeBlock: 'Changer de nœud', maximize: 'Maximiser le Canvas', minimize: 'Sortir du mode plein écran', + scrollToSelectedNode: 'Faites défiler jusqu’au nœud sélectionné', }, nodes: { common: { diff --git a/web/i18n/hi-IN/workflow.ts b/web/i18n/hi-IN/workflow.ts index 13dbc5a912..2d04883762 100644 --- a/web/i18n/hi-IN/workflow.ts +++ b/web/i18n/hi-IN/workflow.ts @@ -327,13 +327,13 @@ const translation = { checklistResolved: 'सभी समस्याएं हल हो गई हैं', change: 'बदलें', optional: '(वैकल्पिक)', - moveToThisNode: 'इस नोड पर जाएं', changeBlock: 'नोड बदलें', addNextStep: 'इस कार्यप्रवाह में अगला कदम जोड़ें', selectNextStep: 'अगला कदम चुनें', organizeBlocks: 'नोड्स का आयोजन करें', minimize: 'पूर्ण स्क्रीन से बाहर निकलें', maximize: 'कैनवास का अधिकतम लाभ उठाएँ', + scrollToSelectedNode: 'चुने गए नोड पर स्क्रॉल करें', }, nodes: { common: { diff --git a/web/i18n/it-IT/workflow.ts b/web/i18n/it-IT/workflow.ts index b342f0b0a8..0b687906fd 100644 --- a/web/i18n/it-IT/workflow.ts +++ b/web/i18n/it-IT/workflow.ts @@ -330,13 +330,13 @@ const translation = { checklistResolved: 'Tutti i problemi sono risolti', change: 'Cambia', optional: '(opzionale)', - moveToThisNode: 'Sposta a questo nodo', changeBlock: 'Cambia Nodo', selectNextStep: 'Seleziona il prossimo passo', organizeBlocks: 'Organizzare i nodi', addNextStep: 'Aggiungi il prossimo passo in questo flusso di lavoro', minimize: 'Esci dalla modalità schermo intero', maximize: 'Massimizza Canvas', + scrollToSelectedNode: 'Scorri fino al nodo selezionato', }, nodes: { common: { diff --git a/web/i18n/ja-JP/workflow.ts b/web/i18n/ja-JP/workflow.ts index 307dc71ec0..a1e4b92482 100644 --- a/web/i18n/ja-JP/workflow.ts +++ b/web/i18n/ja-JP/workflow.ts @@ -326,9 +326,9 @@ const translation = { organizeBlocks: 'ノード整理', change: '変更', optional: '(任意)', - moveToThisNode: 'このノードに移動する', maximize: 'キャンバスを最大化する', minimize: '全画面を終了する', + scrollToSelectedNode: '選択したノードまでスクロール', }, nodes: { common: { diff --git a/web/i18n/ko-KR/workflow.ts b/web/i18n/ko-KR/workflow.ts index 49ca6c6cab..c2ec86f059 100644 --- a/web/i18n/ko-KR/workflow.ts +++ b/web/i18n/ko-KR/workflow.ts @@ -336,13 +336,13 @@ const translation = { checklistResolved: '모든 문제가 해결되었습니다', change: '변경', optional: '(선택사항)', - moveToThisNode: '이 노드로 이동', organizeBlocks: '노드 정리하기', selectNextStep: '다음 단계 선택', changeBlock: '노드 변경', addNextStep: '이 워크플로우에 다음 단계를 추가하세요.', minimize: '전체 화면 종료', maximize: '캔버스 전체 화면', + scrollToSelectedNode: '선택한 노드로 스크롤', }, nodes: { common: { diff --git a/web/i18n/pl-PL/workflow.ts b/web/i18n/pl-PL/workflow.ts index cea026b6c4..8f1d76dcf2 100644 --- a/web/i18n/pl-PL/workflow.ts +++ b/web/i18n/pl-PL/workflow.ts @@ -315,13 +315,13 @@ const translation = { checklistResolved: 'Wszystkie problemy zostały rozwiązane', change: 'Zmień', optional: '(opcjonalne)', - moveToThisNode: 'Przenieś do tego węzła', selectNextStep: 'Wybierz następny krok', addNextStep: 'Dodaj następny krok w tym procesie roboczym', changeBlock: 'Zmień węzeł', organizeBlocks: 'Organizuj węzły', minimize: 'Wyjdź z trybu pełnoekranowego', maximize: 'Maksymalizuj płótno', + scrollToSelectedNode: 'Przewiń do wybranego węzła', }, nodes: { common: { diff --git a/web/i18n/pt-BR/workflow.ts b/web/i18n/pt-BR/workflow.ts index 3c85c37301..a490bc0687 100644 --- a/web/i18n/pt-BR/workflow.ts +++ b/web/i18n/pt-BR/workflow.ts @@ -315,13 +315,13 @@ const translation = { checklistResolved: 'Todos os problemas foram resolvidos', change: 'Mudar', optional: '(opcional)', - moveToThisNode: 'Mova-se para este nó', changeBlock: 'Mudar Nó', addNextStep: 'Adicione o próximo passo neste fluxo de trabalho', organizeBlocks: 'Organizar nós', selectNextStep: 'Selecione o próximo passo', maximize: 'Maximize Canvas', minimize: 'Sair do Modo Tela Cheia', + scrollToSelectedNode: 'Role até o nó selecionado', }, nodes: { common: { diff --git a/web/i18n/ro-RO/workflow.ts b/web/i18n/ro-RO/workflow.ts index 0caec4993c..1e55d53235 100644 --- a/web/i18n/ro-RO/workflow.ts +++ b/web/i18n/ro-RO/workflow.ts @@ -315,13 +315,13 @@ const translation = { checklistResolved: 'Toate problemele au fost rezolvate', change: 'Schimbă', optional: '(opțional)', - moveToThisNode: 'Mutați la acest nod', organizeBlocks: 'Organizează nodurile', addNextStep: 'Adăugați următorul pas în acest flux de lucru', changeBlock: 'Schimbă nodul', selectNextStep: 'Selectați Pasul Următor', maximize: 'Maximize Canvas', minimize: 'Iesi din modul pe tot ecranul', + scrollToSelectedNode: 'Derulați la nodul selectat', }, nodes: { common: { diff --git a/web/i18n/ru-RU/workflow.ts b/web/i18n/ru-RU/workflow.ts index d4c9b81b39..48aa9b6e58 100644 --- a/web/i18n/ru-RU/workflow.ts +++ b/web/i18n/ru-RU/workflow.ts @@ -315,13 +315,13 @@ const translation = { checklistResolved: 'Все проблемы решены', change: 'Изменить', optional: '(необязательно)', - moveToThisNode: 'Перейдите к этому узлу', selectNextStep: 'Выберите следующий шаг', organizeBlocks: 'Организовать узлы', addNextStep: 'Добавьте следующий шаг в этот рабочий процесс', changeBlock: 'Изменить узел', minimize: 'Выйти из полноэкранного режима', maximize: 'Максимизировать холст', + scrollToSelectedNode: 'Прокрутите до выбранного узла', }, nodes: { common: { diff --git a/web/i18n/sl-SI/workflow.ts b/web/i18n/sl-SI/workflow.ts index 2df5271043..c7602b1349 100644 --- a/web/i18n/sl-SI/workflow.ts +++ b/web/i18n/sl-SI/workflow.ts @@ -318,7 +318,6 @@ const translation = { runThisStep: 'Izvedi ta korak', changeBlock: 'Spremeni vozlišče', addNextStep: 'Dodajte naslednji korak v ta delovni potek', - moveToThisNode: 'Premakni se na to vozlišče', checklistTip: 'Prepričajte se, da so vse težave rešene, preden objavite.', selectNextStep: 'Izberi naslednji korak', helpLink: 'Pomočna povezava', @@ -329,6 +328,7 @@ const translation = { minimize: 'Izhod iz celotnega zaslona', maximize: 'Maksimiziraj platno', optional: '(neobvezno)', + scrollToSelectedNode: 'Pomaknite se do izbranega vozlišča', }, nodes: { common: { diff --git a/web/i18n/th-TH/workflow.ts b/web/i18n/th-TH/workflow.ts index eea8e1e300..8d4ca5e7ca 100644 --- a/web/i18n/th-TH/workflow.ts +++ b/web/i18n/th-TH/workflow.ts @@ -315,13 +315,13 @@ const translation = { checklistResolved: 'ปัญหาทั้งหมดได้รับการแก้ไขแล้ว', change: 'เปลี่ยน', optional: '(ไม่บังคับ)', - moveToThisNode: 'ย้ายไปที่โหนดนี้', organizeBlocks: 'จัดระเบียบโหนด', addNextStep: 'เพิ่มขั้นตอนถัดไปในกระบวนการทำงานนี้', changeBlock: 'เปลี่ยนโหนด', selectNextStep: 'เลือกขั้นตอนถัดไป', minimize: 'ออกจากโหมดเต็มหน้าจอ', maximize: 'เพิ่มประสิทธิภาพผ้าใบ', + scrollToSelectedNode: 'เลื่อนไปยังโหนดที่เลือก', }, nodes: { common: { diff --git a/web/i18n/tr-TR/workflow.ts b/web/i18n/tr-TR/workflow.ts index 0a94550eaa..ca4ab32ebf 100644 --- a/web/i18n/tr-TR/workflow.ts +++ b/web/i18n/tr-TR/workflow.ts @@ -315,13 +315,13 @@ const translation = { checklistResolved: 'Tüm sorunlar çözüldü', change: 'Değiştir', optional: '(isteğe bağlı)', - moveToThisNode: 'Bu düğüme geç', changeBlock: 'Düğümü Değiştir', addNextStep: 'Bu iş akışına bir sonraki adımı ekleyin', organizeBlocks: 'Düğümleri düzenle', selectNextStep: 'Sonraki Adımı Seç', minimize: 'Tam Ekrandan Çık', maximize: 'Kanvası Maksimize Et', + scrollToSelectedNode: 'Seçili düğüme kaydırma', }, nodes: { common: { diff --git a/web/i18n/uk-UA/workflow.ts b/web/i18n/uk-UA/workflow.ts index 1df2f444fd..47c1a12128 100644 --- a/web/i18n/uk-UA/workflow.ts +++ b/web/i18n/uk-UA/workflow.ts @@ -315,13 +315,13 @@ const translation = { checklistResolved: 'Всі проблеми вирішені', change: 'Змінити', optional: '(необов\'язково)', - moveToThisNode: 'Перемістіть до цього вузла', organizeBlocks: 'Організуйте вузли', changeBlock: 'Змінити вузол', selectNextStep: 'Виберіть наступний крок', addNextStep: 'Додайте наступний крок у цей робочий процес', minimize: 'Вийти з повноекранного режиму', maximize: 'Максимізувати полотно', + scrollToSelectedNode: 'Прокрутіть до вибраного вузла', }, nodes: { common: { diff --git a/web/i18n/vi-VN/workflow.ts b/web/i18n/vi-VN/workflow.ts index 23482ee06d..ea4488d020 100644 --- a/web/i18n/vi-VN/workflow.ts +++ b/web/i18n/vi-VN/workflow.ts @@ -315,13 +315,13 @@ const translation = { checklistResolved: 'Tất cả các vấn đề đã được giải quyết', change: 'Thay đổi', optional: '(tùy chọn)', - moveToThisNode: 'Di chuyển đến nút này', changeBlock: 'Thay đổi Node', selectNextStep: 'Chọn bước tiếp theo', organizeBlocks: 'Tổ chức các nút', addNextStep: 'Thêm bước tiếp theo trong quy trình này', maximize: 'Tối đa hóa Canvas', minimize: 'Thoát chế độ toàn màn hình', + scrollToSelectedNode: 'Cuộn đến nút đã chọn', }, nodes: { common: { diff --git a/web/i18n/zh-Hans/workflow.ts b/web/i18n/zh-Hans/workflow.ts index cef27f5b9d..5b56f112c5 100644 --- a/web/i18n/zh-Hans/workflow.ts +++ b/web/i18n/zh-Hans/workflow.ts @@ -326,9 +326,9 @@ const translation = { organizeBlocks: '整理节点', change: '更改', optional: '(选填)', - moveToThisNode: '定位至此节点', maximize: '最大化画布', minimize: '退出最大化', + scrollToSelectedNode: '滚动至选中节点', }, nodes: { common: { diff --git a/web/i18n/zh-Hant/workflow.ts b/web/i18n/zh-Hant/workflow.ts index e40221f2bc..659bffa390 100644 --- a/web/i18n/zh-Hant/workflow.ts +++ b/web/i18n/zh-Hant/workflow.ts @@ -319,9 +319,9 @@ const translation = { organizeBlocks: '整理節點', change: '更改', optional: '(選擇性)', - moveToThisNode: '定位至此節點', minimize: '退出全螢幕', maximize: '最大化畫布', + scrollToSelectedNode: '捲動至選取的節點', }, nodes: { common: { From 74ad21b145b9e3540a75ed70aeb0297a08fafec5 Mon Sep 17 00:00:00 2001 From: lyzno1 <92089059+lyzno1@users.noreply.github.com> Date: Mon, 18 Aug 2025 09:23:16 +0800 Subject: [PATCH 003/180] feat: comprehensive trigger node system with Schedule Trigger implementation (#24039) Co-authored-by: zhangxuhe1 --- web/app/components/base/select/index.tsx | 75 +- .../workflow/block-selector/constants.tsx | 27 +- .../workflow/block-selector/hooks.ts | 26 +- .../workflow/block-selector/index.tsx | 6 +- .../workflow/block-selector/start-blocks.tsx | 84 ++ .../workflow/block-selector/tabs.tsx | 16 +- .../workflow/block-selector/types.ts | 1 + web/app/components/workflow/constants.ts | 48 ++ .../components/workflow/hooks/use-workflow.ts | 12 +- .../_base/components/trigger-container.tsx | 43 + .../components/workflow/nodes/_base/node.tsx | 16 +- .../components/workflow/nodes/constants.ts | 12 + .../workflow/nodes/trigger-plugin/default.ts | 30 + .../workflow/nodes/trigger-plugin/node.tsx | 28 + .../workflow/nodes/trigger-plugin/panel.tsx | 29 + .../workflow/nodes/trigger-plugin/types.ts | 8 + .../components/date-time-picker.spec.tsx | 139 +++ .../components/date-time-picker.tsx | 158 ++++ .../components/execute-now-button.tsx | 24 + .../components/frequency-selector.tsx | 38 + .../components/mode-switcher.tsx | 37 + .../components/mode-toggle.tsx | 37 + .../components/monthly-days-selector.tsx | 70 ++ .../components/next-execution-times.tsx | 41 + .../components/recur-config.tsx | 59 ++ .../components/simple-segmented-control.tsx | 60 ++ .../components/time-picker.spec.tsx | 223 +++++ .../components/time-picker.tsx | 230 +++++ .../components/weekday-selector.tsx | 53 ++ .../nodes/trigger-schedule/default.ts | 35 + .../workflow/nodes/trigger-schedule/node.tsx | 27 + .../workflow/nodes/trigger-schedule/panel.tsx | 166 ++++ .../workflow/nodes/trigger-schedule/types.ts | 24 + .../nodes/trigger-schedule/use-config.ts | 106 +++ .../utils/cron-parser.spec.ts | 233 +++++ .../trigger-schedule/utils/cron-parser.ts | 237 ++++++ .../utils/execution-time-calculator.spec.ts | 795 ++++++++++++++++++ .../utils/execution-time-calculator.ts | 200 +++++ .../workflow/nodes/trigger-webhook/default.ts | 31 + .../workflow/nodes/trigger-webhook/node.tsx | 28 + .../workflow/nodes/trigger-webhook/panel.tsx | 29 + .../workflow/nodes/trigger-webhook/types.ts | 10 + .../workflow/operator/add-block.tsx | 3 + web/app/components/workflow/types.ts | 13 + .../workflow/utils/workflow-entry.ts | 26 + web/i18n/en-US/common.ts | 1 + web/i18n/en-US/workflow.ts | 53 ++ web/i18n/ja-JP/common.ts | 1 + web/i18n/ja-JP/workflow.ts | 53 ++ web/i18n/zh-Hans/common.ts | 1 + web/i18n/zh-Hans/workflow.ts | 53 ++ web/themes/dark.css | 2 +- 52 files changed, 3709 insertions(+), 48 deletions(-) create mode 100644 web/app/components/workflow/block-selector/start-blocks.tsx create mode 100644 web/app/components/workflow/nodes/_base/components/trigger-container.tsx create mode 100644 web/app/components/workflow/nodes/trigger-plugin/default.ts create mode 100644 web/app/components/workflow/nodes/trigger-plugin/node.tsx create mode 100644 web/app/components/workflow/nodes/trigger-plugin/panel.tsx create mode 100644 web/app/components/workflow/nodes/trigger-plugin/types.ts create mode 100644 web/app/components/workflow/nodes/trigger-schedule/components/date-time-picker.spec.tsx create mode 100644 web/app/components/workflow/nodes/trigger-schedule/components/date-time-picker.tsx create mode 100644 web/app/components/workflow/nodes/trigger-schedule/components/execute-now-button.tsx create mode 100644 web/app/components/workflow/nodes/trigger-schedule/components/frequency-selector.tsx create mode 100644 web/app/components/workflow/nodes/trigger-schedule/components/mode-switcher.tsx create mode 100644 web/app/components/workflow/nodes/trigger-schedule/components/mode-toggle.tsx create mode 100644 web/app/components/workflow/nodes/trigger-schedule/components/monthly-days-selector.tsx create mode 100644 web/app/components/workflow/nodes/trigger-schedule/components/next-execution-times.tsx create mode 100644 web/app/components/workflow/nodes/trigger-schedule/components/recur-config.tsx create mode 100644 web/app/components/workflow/nodes/trigger-schedule/components/simple-segmented-control.tsx create mode 100644 web/app/components/workflow/nodes/trigger-schedule/components/time-picker.spec.tsx create mode 100644 web/app/components/workflow/nodes/trigger-schedule/components/time-picker.tsx create mode 100644 web/app/components/workflow/nodes/trigger-schedule/components/weekday-selector.tsx create mode 100644 web/app/components/workflow/nodes/trigger-schedule/default.ts create mode 100644 web/app/components/workflow/nodes/trigger-schedule/node.tsx create mode 100644 web/app/components/workflow/nodes/trigger-schedule/panel.tsx create mode 100644 web/app/components/workflow/nodes/trigger-schedule/types.ts create mode 100644 web/app/components/workflow/nodes/trigger-schedule/use-config.ts create mode 100644 web/app/components/workflow/nodes/trigger-schedule/utils/cron-parser.spec.ts create mode 100644 web/app/components/workflow/nodes/trigger-schedule/utils/cron-parser.ts create mode 100644 web/app/components/workflow/nodes/trigger-schedule/utils/execution-time-calculator.spec.ts create mode 100644 web/app/components/workflow/nodes/trigger-schedule/utils/execution-time-calculator.ts create mode 100644 web/app/components/workflow/nodes/trigger-webhook/default.ts create mode 100644 web/app/components/workflow/nodes/trigger-webhook/node.tsx create mode 100644 web/app/components/workflow/nodes/trigger-webhook/panel.tsx create mode 100644 web/app/components/workflow/nodes/trigger-webhook/types.ts create mode 100644 web/app/components/workflow/utils/workflow-entry.ts diff --git a/web/app/components/base/select/index.tsx b/web/app/components/base/select/index.tsx index aa0cf02215..9ad2a489b0 100644 --- a/web/app/components/base/select/index.tsx +++ b/web/app/components/base/select/index.tsx @@ -26,6 +26,8 @@ const defaultItems = [ export type Item = { value: number | string name: string + isGroup?: boolean + disabled?: boolean } & Record export type ISelectProps = { @@ -255,38 +257,47 @@ const SimpleSelect: FC = ({ {(!disabled) && ( - {items.map((item: Item) => ( - - {({ /* active, */ selected }) => ( - <> - {renderOption - ? renderOption({ item, selected }) - : (<> - {item.name} - {selected && !hideChecked && ( - - - )} - )} - - )} - - ))} + {items.map((item: Item) => + item.isGroup ? ( +
+ {item.name} +
+ ) : ( + + {({ /* active, */ selected }) => ( + <> + {renderOption + ? renderOption({ item, selected }) + : (<> + {item.name} + {selected && !hideChecked && ( + + + )} + )} + + )} + + ), + )}
)}
diff --git a/web/app/components/workflow/block-selector/constants.tsx b/web/app/components/workflow/block-selector/constants.tsx index 680cbf45b9..9c429edba8 100644 --- a/web/app/components/workflow/block-selector/constants.tsx +++ b/web/app/components/workflow/block-selector/constants.tsx @@ -2,13 +2,34 @@ import type { Block } from '../types' import { BlockEnum } from '../types' import { BlockClassificationEnum } from './types' -export const BLOCKS: Block[] = [ +export const START_BLOCKS: Block[] = [ { classification: BlockClassificationEnum.Default, type: BlockEnum.Start, - title: 'Start', - description: '', + title: 'User Input', + description: 'Traditional start node for user input', }, + { + classification: BlockClassificationEnum.Default, + type: BlockEnum.TriggerSchedule, + title: 'Schedule Trigger', + description: 'Time-based workflow trigger', + }, + { + classification: BlockClassificationEnum.Default, + type: BlockEnum.TriggerWebhook, + title: 'Webhook Trigger', + description: 'HTTP callback trigger', + }, + { + classification: BlockClassificationEnum.Default, + type: BlockEnum.TriggerPlugin, + title: 'Plugin Trigger', + description: 'Third-party integration trigger', + }, +] + +export const BLOCKS: Block[] = [ { classification: BlockClassificationEnum.Default, type: BlockEnum.LLM, diff --git a/web/app/components/workflow/block-selector/hooks.ts b/web/app/components/workflow/block-selector/hooks.ts index d00815584d..29d54f12f4 100644 --- a/web/app/components/workflow/block-selector/hooks.ts +++ b/web/app/components/workflow/block-selector/hooks.ts @@ -1,5 +1,5 @@ import { useTranslation } from 'react-i18next' -import { BLOCKS } from './constants' +import { BLOCKS, START_BLOCKS } from './constants' import { TabsEnum, ToolTypeEnum, @@ -16,10 +16,21 @@ export const useBlocks = () => { }) } -export const useTabs = () => { +export const useStartBlocks = () => { const { t } = useTranslation() - return [ + return START_BLOCKS.map((block) => { + return { + ...block, + title: t(`workflow.blocks.${block.type}`), + } + }) +} + +export const useTabs = (showStartTab = false) => { + const { t } = useTranslation() + + const tabs = [ { key: TabsEnum.Blocks, name: t('workflow.tabs.blocks'), @@ -29,6 +40,15 @@ export const useTabs = () => { name: t('workflow.tabs.tools'), }, ] + + if (showStartTab) { + tabs.push({ + key: TabsEnum.Start, + name: t('workflow.tabs.start'), + }) + } + + return tabs } export const useToolTabs = (isHideMCPTools?: boolean) => { diff --git a/web/app/components/workflow/block-selector/index.tsx b/web/app/components/workflow/block-selector/index.tsx index 0673ca0c0d..96433b37ef 100644 --- a/web/app/components/workflow/block-selector/index.tsx +++ b/web/app/components/workflow/block-selector/index.tsx @@ -43,6 +43,7 @@ type NodeSelectorProps = { availableBlocksTypes?: BlockEnum[] disabled?: boolean noBlocks?: boolean + showStartTab?: boolean } const NodeSelector: FC = ({ open: openFromProps, @@ -59,6 +60,7 @@ const NodeSelector: FC = ({ availableBlocksTypes, disabled, noBlocks = false, + showStartTab = false, }) => { const { t } = useTranslation() const [searchText, setSearchText] = useState('') @@ -90,9 +92,10 @@ const NodeSelector: FC = ({ setActiveTab(newActiveTab) }, []) const searchPlaceholder = useMemo(() => { + if (activeTab === TabsEnum.Start) + return t('workflow.tabs.searchBlock') if (activeTab === TabsEnum.Blocks) return t('workflow.tabs.searchBlock') - if (activeTab === TabsEnum.Tools) return t('workflow.tabs.searchTool') return '' @@ -163,6 +166,7 @@ const NodeSelector: FC = ({ tags={tags} availableBlocksTypes={availableBlocksTypes} noBlocks={noBlocks} + showStartTab={showStartTab} />
diff --git a/web/app/components/workflow/block-selector/start-blocks.tsx b/web/app/components/workflow/block-selector/start-blocks.tsx new file mode 100644 index 0000000000..b8e9f3185a --- /dev/null +++ b/web/app/components/workflow/block-selector/start-blocks.tsx @@ -0,0 +1,84 @@ +import { + memo, + useCallback, + useMemo, +} from 'react' +import { useTranslation } from 'react-i18next' +import BlockIcon from '../block-icon' +import type { BlockEnum } from '../types' +import { useNodesExtraData } from '../hooks' +import { START_BLOCKS } from './constants' +import type { ToolDefaultValue } from './types' +import Tooltip from '@/app/components/base/tooltip' + +type StartBlocksProps = { + searchText: string + onSelect: (type: BlockEnum, tool?: ToolDefaultValue) => void + availableBlocksTypes?: BlockEnum[] +} + +const StartBlocks = ({ + searchText, + onSelect, + availableBlocksTypes = [], +}: StartBlocksProps) => { + const { t } = useTranslation() + const nodesExtraData = useNodesExtraData() + + const filteredBlocks = useMemo(() => { + return START_BLOCKS.filter((block) => { + return block.title.toLowerCase().includes(searchText.toLowerCase()) + && availableBlocksTypes.includes(block.type) + }) + }, [searchText, availableBlocksTypes]) + + const isEmpty = filteredBlocks.length === 0 + + const renderBlock = useCallback((block: typeof START_BLOCKS[0]) => ( + + +
{block.title}
+
{nodesExtraData[block.type].about}
+
+ )} + > +
onSelect(block.type)} + > + +
{block.title}
+
+ + ), [nodesExtraData, onSelect]) + + return ( +
+ {isEmpty && ( +
+ {t('workflow.tabs.noResult')} +
+ )} + {!isEmpty && ( +
+ {filteredBlocks.map(renderBlock)} +
+ )} +
+ ) +} + +export default memo(StartBlocks) diff --git a/web/app/components/workflow/block-selector/tabs.tsx b/web/app/components/workflow/block-selector/tabs.tsx index 3f3fed2ca9..9c6e4253f4 100644 --- a/web/app/components/workflow/block-selector/tabs.tsx +++ b/web/app/components/workflow/block-selector/tabs.tsx @@ -6,6 +6,7 @@ import { useTabs } from './hooks' import type { ToolDefaultValue } from './types' import { TabsEnum } from './types' import Blocks from './blocks' +import StartBlocks from './start-blocks' import AllTools from './all-tools' import cn from '@/utils/classnames' @@ -18,6 +19,7 @@ export type TabsProps = { availableBlocksTypes?: BlockEnum[] filterElem: React.ReactNode noBlocks?: boolean + showStartTab?: boolean } const Tabs: FC = ({ activeTab, @@ -28,8 +30,9 @@ const Tabs: FC = ({ availableBlocksTypes, filterElem, noBlocks, + showStartTab = false, }) => { - const tabs = useTabs() + const tabs = useTabs(showStartTab) const { data: buildInTools } = useAllBuiltInTools() const { data: customTools } = useAllCustomTools() const { data: workflowTools } = useAllWorkflowTools() @@ -60,6 +63,17 @@ const Tabs: FC = ({ ) } {filterElem} + { + activeTab === TabsEnum.Start && !noBlocks && ( +
+ +
+ ) + } { activeTab === TabsEnum.Blocks && !noBlocks && (
diff --git a/web/app/components/workflow/block-selector/types.ts b/web/app/components/workflow/block-selector/types.ts index 361d4ccc9d..a4d358525e 100644 --- a/web/app/components/workflow/block-selector/types.ts +++ b/web/app/components/workflow/block-selector/types.ts @@ -1,6 +1,7 @@ import type { PluginMeta } from '../../plugins/types' export enum TabsEnum { + Start = 'start', Blocks = 'blocks', Tools = 'tools', } diff --git a/web/app/components/workflow/constants.ts b/web/app/components/workflow/constants.ts index 5bf053e2c5..9728e44b05 100644 --- a/web/app/components/workflow/constants.ts +++ b/web/app/components/workflow/constants.ts @@ -22,6 +22,9 @@ import IterationStartDefault from './nodes/iteration-start/default' import AgentDefault from './nodes/agent/default' import LoopStartDefault from './nodes/loop-start/default' import LoopEndDefault from './nodes/loop-end/default' +import TriggerScheduleDefault from './nodes/trigger-schedule/default' +import TriggerWebhookDefault from './nodes/trigger-webhook/default' +import TriggerPluginDefault from './nodes/trigger-plugin/default' type NodesExtraData = { author: string @@ -242,6 +245,33 @@ export const NODES_EXTRA_DATA: Record = { getAvailableNextNodes: ListFilterDefault.getAvailableNextNodes, checkValid: AgentDefault.checkValid, }, + [BlockEnum.TriggerSchedule]: { + author: 'Dify', + about: '', + availablePrevNodes: [], + availableNextNodes: [], + getAvailablePrevNodes: TriggerScheduleDefault.getAvailablePrevNodes, + getAvailableNextNodes: TriggerScheduleDefault.getAvailableNextNodes, + checkValid: TriggerScheduleDefault.checkValid, + }, + [BlockEnum.TriggerWebhook]: { + author: 'Dify', + about: '', + availablePrevNodes: [], + availableNextNodes: [], + getAvailablePrevNodes: TriggerWebhookDefault.getAvailablePrevNodes, + getAvailableNextNodes: TriggerWebhookDefault.getAvailableNextNodes, + checkValid: TriggerWebhookDefault.checkValid, + }, + [BlockEnum.TriggerPlugin]: { + author: 'Dify', + about: '', + availablePrevNodes: [], + availableNextNodes: [], + getAvailablePrevNodes: TriggerPluginDefault.getAvailablePrevNodes, + getAvailableNextNodes: TriggerPluginDefault.getAvailableNextNodes, + checkValid: TriggerPluginDefault.checkValid, + }, } export const NODES_INITIAL_DATA = { @@ -401,6 +431,24 @@ export const NODES_INITIAL_DATA = { desc: '', ...AgentDefault.defaultValue, }, + [BlockEnum.TriggerSchedule]: { + type: BlockEnum.TriggerSchedule, + title: '', + desc: '', + ...TriggerScheduleDefault.defaultValue, + }, + [BlockEnum.TriggerWebhook]: { + type: BlockEnum.TriggerWebhook, + title: '', + desc: '', + ...TriggerWebhookDefault.defaultValue, + }, + [BlockEnum.TriggerPlugin]: { + type: BlockEnum.TriggerPlugin, + title: '', + desc: '', + ...TriggerPluginDefault.defaultValue, + }, } export const MAX_ITERATION_PARALLEL_NUM = 10 export const MIN_ITERATION_PARALLEL_NUM = 1 diff --git a/web/app/components/workflow/hooks/use-workflow.ts b/web/app/components/workflow/hooks/use-workflow.ts index 387567da0a..0aaaad689a 100644 --- a/web/app/components/workflow/hooks/use-workflow.ts +++ b/web/app/components/workflow/hooks/use-workflow.ts @@ -28,6 +28,10 @@ import { import { getParallelInfo, } from '../utils' +import { + getWorkflowEntryNode, + isWorkflowEntryNode, +} from '../utils/workflow-entry' import { PARALLEL_DEPTH_LIMIT, SUPPORT_OUTPUT_VARS_NODE, @@ -67,7 +71,7 @@ export const useWorkflow = () => { edges, } = store.getState() const nodes = getNodes() - let startNode = nodes.find(node => node.data.type === BlockEnum.Start) + let startNode = getWorkflowEntryNode(nodes) const currentNode = nodes.find(node => node.id === nodeId) if (currentNode?.parentId) @@ -238,14 +242,14 @@ export const useWorkflow = () => { if (!currentNode) return false - if (currentNode.data.type === BlockEnum.Start) + if (isWorkflowEntryNode(currentNode.data.type)) return true const checkPreviousNodes = (node: Node) => { const previousNodes = getBeforeNodeById(node.id) for (const prevNode of previousNodes) { - if (prevNode.data.type === BlockEnum.Start) + if (isWorkflowEntryNode(prevNode.data.type)) return true if (checkPreviousNodes(prevNode)) return true @@ -390,7 +394,7 @@ export const useWorkflow = () => { const { getNodes } = store.getState() const nodes = getNodes() - return nodes.find(node => node.id === nodeId) || nodes.find(node => node.data.type === BlockEnum.Start) + return nodes.find(node => node.id === nodeId) || getWorkflowEntryNode(nodes) }, [store]) return { diff --git a/web/app/components/workflow/nodes/_base/components/trigger-container.tsx b/web/app/components/workflow/nodes/_base/components/trigger-container.tsx new file mode 100644 index 0000000000..97853126c0 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/trigger-container.tsx @@ -0,0 +1,43 @@ +import type { FC, ReactNode } from 'react' +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import cn from '@/utils/classnames' + +export type TriggerStatus = 'enabled' | 'disabled' + +type TriggerContainerProps = { + children: ReactNode + status?: TriggerStatus + customLabel?: string +} + +const TriggerContainer: FC = ({ + children, + status = 'enabled', + customLabel, +}) => { + const { t } = useTranslation() + + const statusConfig = useMemo(() => { + const isDisabled = status === 'disabled' + + return { + label: customLabel || (isDisabled ? t('workflow.triggerStatus.disabled') : t('workflow.triggerStatus.enabled')), + dotColor: isDisabled ? 'bg-text-tertiary' : 'bg-green-500', + } + }, [status, customLabel, t]) + + return ( +
+
+
+ + {statusConfig.label} + +
+ {children} +
+ ) +} + +export default TriggerContainer diff --git a/web/app/components/workflow/nodes/_base/node.tsx b/web/app/components/workflow/nodes/_base/node.tsx index c2600fd035..233b0af42e 100644 --- a/web/app/components/workflow/nodes/_base/node.tsx +++ b/web/app/components/workflow/nodes/_base/node.tsx @@ -20,6 +20,7 @@ import type { NodeProps } from '../../types' import { BlockEnum, NodeRunningStatus, + TRIGGER_NODE_TYPES, } from '../../types' import { useNodesReadOnly, @@ -42,6 +43,7 @@ import NodeControl from './components/node-control' import ErrorHandleOnNode from './components/error-handle/error-handle-on-node' import RetryOnNode from './components/retry/retry-on-node' import AddVariablePopupWithPosition from './components/add-variable-popup-with-position' +import TriggerContainer from './components/trigger-container' import cn from '@/utils/classnames' import BlockIcon from '@/app/components/workflow/block-icon' import Tooltip from '@/app/components/base/tooltip' @@ -136,7 +138,9 @@ const BaseNode: FC = ({ return null }, [data._loopIndex, data._runningStatus, t]) - return ( + const isTriggerNode = TRIGGER_NODE_TYPES.includes(data.type as any) + + const nodeContent = (
= ({
{ data.type !== BlockEnum.Iteration && data.type !== BlockEnum.Loop && ( - cloneElement(children, { id, data }) + cloneElement(children, { data } as any) ) } { (data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) && (
- {cloneElement(children, { id, data })} + {cloneElement(children, { data } as any)}
) } @@ -332,6 +336,12 @@ const BaseNode: FC = ({
) + + return isTriggerNode ? ( + + {nodeContent} + + ) : nodeContent } export default memo(BaseNode) diff --git a/web/app/components/workflow/nodes/constants.ts b/web/app/components/workflow/nodes/constants.ts index 0cd6922233..ee2fc7a459 100644 --- a/web/app/components/workflow/nodes/constants.ts +++ b/web/app/components/workflow/nodes/constants.ts @@ -38,6 +38,12 @@ import ListFilterNode from './list-operator/node' import ListFilterPanel from './list-operator/panel' import AgentNode from './agent/node' import AgentPanel from './agent/panel' +import TriggerScheduleNode from './trigger-schedule/node' +import TriggerSchedulePanel from './trigger-schedule/panel' +import TriggerWebhookNode from './trigger-webhook/node' +import TriggerWebhookPanel from './trigger-webhook/panel' +import TriggerPluginNode from './trigger-plugin/node' +import TriggerPluginPanel from './trigger-plugin/panel' import { TransferMethod } from '@/types/app' export const NodeComponentMap: Record> = { @@ -61,6 +67,9 @@ export const NodeComponentMap: Record> = { [BlockEnum.DocExtractor]: DocExtractorNode, [BlockEnum.ListFilter]: ListFilterNode, [BlockEnum.Agent]: AgentNode, + [BlockEnum.TriggerSchedule]: TriggerScheduleNode, + [BlockEnum.TriggerWebhook]: TriggerWebhookNode, + [BlockEnum.TriggerPlugin]: TriggerPluginNode, } export const PanelComponentMap: Record> = { @@ -84,6 +93,9 @@ export const PanelComponentMap: Record> = { [BlockEnum.DocExtractor]: DocExtractorPanel, [BlockEnum.ListFilter]: ListFilterPanel, [BlockEnum.Agent]: AgentPanel, + [BlockEnum.TriggerSchedule]: TriggerSchedulePanel, + [BlockEnum.TriggerWebhook]: TriggerWebhookPanel, + [BlockEnum.TriggerPlugin]: TriggerPluginPanel, } export const CUSTOM_NODE_TYPE = 'custom' diff --git a/web/app/components/workflow/nodes/trigger-plugin/default.ts b/web/app/components/workflow/nodes/trigger-plugin/default.ts new file mode 100644 index 0000000000..3c7103e499 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-plugin/default.ts @@ -0,0 +1,30 @@ +import { BlockEnum } from '../../types' +import type { NodeDefault } from '../../types' +import type { PluginTriggerNodeType } from './types' +import { ALL_CHAT_AVAILABLE_BLOCKS, ALL_COMPLETION_AVAILABLE_BLOCKS } from '@/app/components/workflow/blocks' + +const nodeDefault: NodeDefault = { + defaultValue: { + plugin_id: '', + plugin_name: '', + event_type: '', + config: {}, + }, + getAvailablePrevNodes(isChatMode: boolean) { + return [] + }, + getAvailableNextNodes(isChatMode: boolean) { + const nodes = isChatMode + ? ALL_CHAT_AVAILABLE_BLOCKS + : ALL_COMPLETION_AVAILABLE_BLOCKS.filter(type => type !== BlockEnum.End) + return nodes.filter(type => type !== BlockEnum.Start) + }, + checkValid(payload: PluginTriggerNodeType, t: any) { + return { + isValid: true, + errorMessage: '', + } + }, +} + +export default nodeDefault diff --git a/web/app/components/workflow/nodes/trigger-plugin/node.tsx b/web/app/components/workflow/nodes/trigger-plugin/node.tsx new file mode 100644 index 0000000000..03b8010cce --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-plugin/node.tsx @@ -0,0 +1,28 @@ +import type { FC } from 'react' +import React from 'react' +import { useTranslation } from 'react-i18next' +import type { PluginTriggerNodeType } from './types' +import type { NodeProps } from '@/app/components/workflow/types' + +const i18nPrefix = 'workflow.nodes.triggerPlugin' + +const Node: FC> = ({ + data, +}) => { + const { t } = useTranslation() + + return ( +
+
+ {t(`${i18nPrefix}.nodeTitle`)} +
+ {data.plugin_name && ( +
+ {data.plugin_name} +
+ )} +
+ ) +} + +export default React.memo(Node) diff --git a/web/app/components/workflow/nodes/trigger-plugin/panel.tsx b/web/app/components/workflow/nodes/trigger-plugin/panel.tsx new file mode 100644 index 0000000000..7913d06415 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-plugin/panel.tsx @@ -0,0 +1,29 @@ +import type { FC } from 'react' +import React from 'react' +import { useTranslation } from 'react-i18next' +import type { PluginTriggerNodeType } from './types' +import Field from '@/app/components/workflow/nodes/_base/components/field' +import type { NodePanelProps } from '@/app/components/workflow/types' + +const i18nPrefix = 'workflow.nodes.triggerPlugin' + +const Panel: FC> = ({ + id, + data, +}) => { + const { t } = useTranslation() + + return ( +
+
+ +
+ {t(`${i18nPrefix}.configPlaceholder`)} +
+
+
+
+ ) +} + +export default React.memo(Panel) diff --git a/web/app/components/workflow/nodes/trigger-plugin/types.ts b/web/app/components/workflow/nodes/trigger-plugin/types.ts new file mode 100644 index 0000000000..b61f32ea4f --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-plugin/types.ts @@ -0,0 +1,8 @@ +import type { CommonNodeType } from '@/app/components/workflow/types' + +export type PluginTriggerNodeType = CommonNodeType & { + plugin_id?: string + plugin_name?: string + event_type?: string + config?: Record +} diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/date-time-picker.spec.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/date-time-picker.spec.tsx new file mode 100644 index 0000000000..4d5a55029a --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/components/date-time-picker.spec.tsx @@ -0,0 +1,139 @@ +import React from 'react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import DateTimePicker from './date-time-picker' + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + 'workflow.nodes.triggerSchedule.selectDateTime': 'Select Date & Time', + 'common.operation.now': 'Now', + 'common.operation.ok': 'OK', + } + return translations[key] || key + }, + }), +})) + +describe('DateTimePicker', () => { + const mockOnChange = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + }) + + test('renders with default value', () => { + render() + + const button = screen.getByRole('button') + expect(button).toBeInTheDocument() + expect(button.textContent).toMatch(/\d+, \d{4} \d{1,2}:\d{2} [AP]M/) + }) + + test('renders with provided value', () => { + const testDate = new Date('2024-01-15T14:30:00.000Z') + render() + + const button = screen.getByRole('button') + expect(button).toBeInTheDocument() + }) + + test('opens picker when button is clicked', () => { + render() + + const button = screen.getByRole('button') + fireEvent.click(button) + + expect(screen.getByText('Select Date & Time')).toBeInTheDocument() + expect(screen.getByText('Now')).toBeInTheDocument() + expect(screen.getByText('OK')).toBeInTheDocument() + }) + + test('closes picker when clicking outside', () => { + render() + + const button = screen.getByRole('button') + fireEvent.click(button) + + expect(screen.getByText('Select Date & Time')).toBeInTheDocument() + + const overlay = document.querySelector('.fixed.inset-0') + fireEvent.click(overlay!) + + expect(screen.queryByText('Select Date & Time')).not.toBeInTheDocument() + }) + + test('does not call onChange when input changes without clicking OK', () => { + render() + + const button = screen.getByRole('button') + fireEvent.click(button) + + const input = screen.getByDisplayValue(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/) + fireEvent.change(input, { target: { value: '2024-12-25T15:30' } }) + + const overlay = document.querySelector('.fixed.inset-0') + fireEvent.click(overlay!) + + expect(mockOnChange).not.toHaveBeenCalled() + }) + + test('calls onChange when clicking OK button', () => { + render() + + const button = screen.getByRole('button') + fireEvent.click(button) + + const input = screen.getByDisplayValue(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/) + fireEvent.change(input, { target: { value: '2024-12-25T15:30' } }) + + const okButton = screen.getByText('OK') + fireEvent.click(okButton) + + expect(mockOnChange).toHaveBeenCalledWith(expect.stringMatching(/2024-12-25T.*:30.*Z/)) + }) + + test('calls onChange when clicking Now button', () => { + render() + + const button = screen.getByRole('button') + fireEvent.click(button) + + const nowButton = screen.getByText('Now') + fireEvent.click(nowButton) + + expect(mockOnChange).toHaveBeenCalledWith(expect.stringMatching(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/)) + }) + + test('resets temp value when reopening picker', async () => { + render() + + const button = screen.getByRole('button') + fireEvent.click(button) + + const input = screen.getByDisplayValue(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/) + const originalValue = input.getAttribute('value') + + fireEvent.change(input, { target: { value: '2024-12-25T15:30' } }) + expect(input.getAttribute('value')).toBe('2024-12-25T15:30') + + const overlay = document.querySelector('.fixed.inset-0') + fireEvent.click(overlay!) + + fireEvent.click(button) + + await waitFor(() => { + const newInput = screen.getByDisplayValue(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/) + expect(newInput.getAttribute('value')).toBe(originalValue) + }) + }) + + test('displays current value in button text', () => { + const testDate = new Date('2024-01-15T14:30:00.000Z') + render() + + const button = screen.getByRole('button') + expect(button.textContent).toMatch(/January 15, 2024/) + expect(button.textContent).toMatch(/\d{1,2}:30 [AP]M/) + }) +}) diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/date-time-picker.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/date-time-picker.tsx new file mode 100644 index 0000000000..5c8dffadd0 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/components/date-time-picker.tsx @@ -0,0 +1,158 @@ +import React, { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { RiCalendarLine } from '@remixicon/react' +import { getDefaultDateTime } from '../utils/execution-time-calculator' + +type DateTimePickerProps = { + value?: string + onChange: (datetime: string) => void +} + +const DateTimePicker = ({ value, onChange }: DateTimePickerProps) => { + const { t } = useTranslation() + const [isOpen, setIsOpen] = useState(false) + const [tempValue, setTempValue] = useState('') + + React.useEffect(() => { + if (isOpen) + setTempValue('') + }, [isOpen]) + + const getCurrentDateTime = () => { + if (value) { + try { + const date = new Date(value) + return `${date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + })} ${date.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + })}` + } + catch { + // fallback + } + } + + const defaultDate = getDefaultDateTime() + + return `${defaultDate.toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + })} ${defaultDate.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + })}` + } + + const handleDateTimeChange = (event: React.ChangeEvent) => { + const dateTimeValue = event.target.value + setTempValue(dateTimeValue) + } + + const getInputValue = () => { + if (tempValue) + return tempValue + + if (value) { + try { + const date = new Date(value) + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + const hours = String(date.getHours()).padStart(2, '0') + const minutes = String(date.getMinutes()).padStart(2, '0') + return `${year}-${month}-${day}T${hours}:${minutes}` + } + catch { + // fallback + } + } + + const defaultDate = getDefaultDateTime() + const year = defaultDate.getFullYear() + const month = String(defaultDate.getMonth() + 1).padStart(2, '0') + const day = String(defaultDate.getDate()).padStart(2, '0') + const hours = String(defaultDate.getHours()).padStart(2, '0') + const minutes = String(defaultDate.getMinutes()).padStart(2, '0') + return `${year}-${month}-${day}T${hours}:${minutes}` + } + + return ( +
+ + + {isOpen && ( +
+
+

{t('workflow.nodes.triggerSchedule.selectDateTime')}

+
+ +
+ +
+ +
+ +
+ + +
+
+ )} + + {isOpen && ( +
{ + setTempValue('') + setIsOpen(false) + }} + /> + )} +
+ ) +} + +export default DateTimePicker diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/execute-now-button.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/execute-now-button.tsx new file mode 100644 index 0000000000..8acf252c05 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/components/execute-now-button.tsx @@ -0,0 +1,24 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' + +type ExecuteNowButtonProps = { + onClick: () => void + disabled?: boolean +} + +const ExecuteNowButton = ({ onClick, disabled = false }: ExecuteNowButtonProps) => { + const { t } = useTranslation() + + return ( + + ) +} + +export default ExecuteNowButton diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/frequency-selector.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/frequency-selector.tsx new file mode 100644 index 0000000000..fa48a66350 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/components/frequency-selector.tsx @@ -0,0 +1,38 @@ +import React, { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { SimpleSelect } from '@/app/components/base/select' +import type { ScheduleFrequency } from '../types' + +type FrequencySelectorProps = { + frequency: ScheduleFrequency + onChange: (frequency: ScheduleFrequency) => void +} + +const FrequencySelector = ({ frequency, onChange }: FrequencySelectorProps) => { + const { t } = useTranslation() + + const frequencies = useMemo(() => [ + { value: 'frequency-header', name: t('workflow.nodes.triggerSchedule.frequency.label'), isGroup: true }, + { value: 'hourly', name: t('workflow.nodes.triggerSchedule.frequency.hourly') }, + { value: 'daily', name: t('workflow.nodes.triggerSchedule.frequency.daily') }, + { value: 'weekly', name: t('workflow.nodes.triggerSchedule.frequency.weekly') }, + { value: 'monthly', name: t('workflow.nodes.triggerSchedule.frequency.monthly') }, + { value: 'once', name: t('workflow.nodes.triggerSchedule.frequency.once') }, + ], [t]) + + return ( + onChange(item.value as ScheduleFrequency)} + placeholder={t('workflow.nodes.triggerSchedule.selectFrequency')} + className="w-full" + optionWrapClassName="min-w-40" + notClearable={true} + allowSearch={false} + /> + ) +} + +export default FrequencySelector diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/mode-switcher.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/mode-switcher.tsx new file mode 100644 index 0000000000..6dc88c85bf --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/components/mode-switcher.tsx @@ -0,0 +1,37 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import { RiCalendarLine, RiCodeLine } from '@remixicon/react' +import { SegmentedControl } from '@/app/components/base/segmented-control' +import type { ScheduleMode } from '../types' + +type ModeSwitcherProps = { + mode: ScheduleMode + onChange: (mode: ScheduleMode) => void +} + +const ModeSwitcher = ({ mode, onChange }: ModeSwitcherProps) => { + const { t } = useTranslation() + + const options = [ + { + Icon: RiCalendarLine, + text: t('workflow.nodes.triggerSchedule.mode.visual'), + value: 'visual' as const, + }, + { + Icon: RiCodeLine, + text: t('workflow.nodes.triggerSchedule.mode.cron'), + value: 'cron' as const, + }, + ] + + return ( + + ) +} + +export default ModeSwitcher diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/mode-toggle.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/mode-toggle.tsx new file mode 100644 index 0000000000..33c03765d0 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/components/mode-toggle.tsx @@ -0,0 +1,37 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import { RiAsterisk, RiCalendarLine } from '@remixicon/react' +import type { ScheduleMode } from '../types' + +type ModeToggleProps = { + mode: ScheduleMode + onChange: (mode: ScheduleMode) => void +} + +const ModeToggle = ({ mode, onChange }: ModeToggleProps) => { + const { t } = useTranslation() + + const handleToggle = () => { + const newMode = mode === 'visual' ? 'cron' : 'visual' + onChange(newMode) + } + + const currentText = mode === 'visual' + ? t('workflow.nodes.triggerSchedule.useCronExpression') + : t('workflow.nodes.triggerSchedule.useVisualPicker') + + const currentIcon = mode === 'visual' ? RiAsterisk : RiCalendarLine + + return ( + + ) +} + +export default ModeToggle diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/monthly-days-selector.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/monthly-days-selector.tsx new file mode 100644 index 0000000000..4c9c8b75b6 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/components/monthly-days-selector.tsx @@ -0,0 +1,70 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import { RiQuestionLine } from '@remixicon/react' +import Tooltip from '@/app/components/base/tooltip' + +type MonthlyDaysSelectorProps = { + selectedDay: number | 'last' + onChange: (day: number | 'last') => void +} + +const MonthlyDaysSelector = ({ selectedDay, onChange }: MonthlyDaysSelectorProps) => { + const { t } = useTranslation() + + const days = Array.from({ length: 31 }, (_, i) => i + 1) + const rows = [ + days.slice(0, 7), + days.slice(7, 14), + days.slice(14, 21), + days.slice(21, 28), + [29, 30, 31, 'last' as const], + ] + + return ( +
+ + +
+ {rows.map((row, rowIndex) => ( +
+ {row.map(day => ( + + ))} + {/* Fill empty cells in the last row (Last day takes 2 cols, so need 1 less) */} + {rowIndex === rows.length - 1 && Array.from({ length: 7 - row.length - 1 }, (_, i) => ( +
+ ))} +
+ ))} +
+
+ ) +} + +export default MonthlyDaysSelector diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/next-execution-times.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/next-execution-times.tsx new file mode 100644 index 0000000000..2c1c790af7 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/components/next-execution-times.tsx @@ -0,0 +1,41 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import type { ScheduleTriggerNodeType } from '../types' +import { getFormattedExecutionTimes } from '../utils/execution-time-calculator' + +type NextExecutionTimesProps = { + data: ScheduleTriggerNodeType +} + +const NextExecutionTimes = ({ data }: NextExecutionTimesProps) => { + const { t } = useTranslation() + + // Don't show next execution times for 'once' frequency + if (data.frequency === 'once') + return null + + const executionTimes = getFormattedExecutionTimes(data, 5) + + if (executionTimes.length === 0) + return null + + return ( +
+ +
+ {executionTimes.map((time, index) => ( +
+ + {String(index + 1).padStart(2, '0')} + + {time} +
+ ))} +
+
+ ) +} + +export default NextExecutionTimes diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/recur-config.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/recur-config.tsx new file mode 100644 index 0000000000..ffbd38aa0e --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/components/recur-config.tsx @@ -0,0 +1,59 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import { InputNumber } from '@/app/components/base/input-number' +import { SimpleSegmentedControl } from './simple-segmented-control' + +type RecurConfigProps = { + recurEvery?: number + recurUnit?: 'hours' | 'minutes' + onRecurEveryChange: (value: number) => void + onRecurUnitChange: (unit: 'hours' | 'minutes') => void +} + +const RecurConfig = ({ + recurEvery = 1, + recurUnit = 'hours', + onRecurEveryChange, + onRecurUnitChange, +}: RecurConfigProps) => { + const { t } = useTranslation() + + const unitOptions = [ + { + text: t('workflow.nodes.triggerSchedule.hours'), + value: 'hours' as const, + }, + { + text: t('workflow.nodes.triggerSchedule.minutes'), + value: 'minutes' as const, + }, + ] + + return ( +
+
+ + onRecurEveryChange(value || 1)} + min={1} + className="text-center" + /> +
+
+ + +
+
+ ) +} + +export default RecurConfig diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/simple-segmented-control.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/simple-segmented-control.tsx new file mode 100644 index 0000000000..695e62fd90 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/components/simple-segmented-control.tsx @@ -0,0 +1,60 @@ +import React from 'react' +import classNames from '@/utils/classnames' +import Divider from '@/app/components/base/divider' + +// Simplified version without icons +type SimpleSegmentedControlProps = { + options: { text: string, value: T }[] + value: T + onChange: (value: T) => void + className?: string +} + +export const SimpleSegmentedControl = ({ + options, + value, + onChange, + className, +}: SimpleSegmentedControlProps) => { + const selectedOptionIndex = options.findIndex(option => option.value === value) + + return ( +
+ {options.map((option, index) => { + const isSelected = index === selectedOptionIndex + const isNextSelected = index === selectedOptionIndex - 1 + const isLast = index === options.length - 1 + return ( + + ) + })} +
+ ) +} + +export default React.memo(SimpleSegmentedControl) as typeof SimpleSegmentedControl diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/time-picker.spec.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/time-picker.spec.tsx new file mode 100644 index 0000000000..2464c63ba5 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/components/time-picker.spec.tsx @@ -0,0 +1,223 @@ +import React from 'react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import TimePicker from './time-picker' + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + 'time.title.pickTime': 'Pick Time', + 'common.operation.now': 'Now', + 'common.operation.ok': 'OK', + } + return translations[key] || key + }, + }), +})) + +describe('TimePicker', () => { + const mockOnChange = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + }) + + test('renders with default value', () => { + render() + + const button = screen.getByRole('button') + expect(button).toBeInTheDocument() + expect(button.textContent).toBe('11:30 AM') + }) + + test('renders with provided value', () => { + render() + + const button = screen.getByRole('button') + expect(button.textContent).toBe('2:30 PM') + }) + + test('opens picker when button is clicked', () => { + render() + + const button = screen.getByRole('button') + fireEvent.click(button) + + expect(screen.getByText('Pick Time')).toBeInTheDocument() + expect(screen.getByText('Now')).toBeInTheDocument() + expect(screen.getByText('OK')).toBeInTheDocument() + }) + + test('closes picker when clicking outside', () => { + render() + + const button = screen.getByRole('button') + fireEvent.click(button) + + expect(screen.getByText('Pick Time')).toBeInTheDocument() + + const overlay = document.querySelector('.fixed.inset-0') + fireEvent.click(overlay!) + + expect(screen.queryByText('Pick Time')).not.toBeInTheDocument() + }) + + test('button text remains unchanged when selecting time without clicking OK', () => { + render() + + const button = screen.getByRole('button') + expect(button.textContent).toBe('11:30 AM') + + fireEvent.click(button) + + const hourButton = screen.getByText('3') + fireEvent.click(hourButton) + + const minuteButton = screen.getByText('45') + fireEvent.click(minuteButton) + + const pmButton = screen.getByText('PM') + fireEvent.click(pmButton) + + expect(button.textContent).toBe('11:30 AM') + expect(mockOnChange).not.toHaveBeenCalled() + + const overlay = document.querySelector('.fixed.inset-0') + fireEvent.click(overlay!) + + expect(button.textContent).toBe('11:30 AM') + }) + + test('calls onChange when clicking OK button', () => { + render() + + const button = screen.getByRole('button') + fireEvent.click(button) + + const hourButton = screen.getByText('3') + fireEvent.click(hourButton) + + const minuteButton = screen.getByText('45') + fireEvent.click(minuteButton) + + const pmButton = screen.getByText('PM') + fireEvent.click(pmButton) + + const okButton = screen.getByText('OK') + fireEvent.click(okButton) + + expect(mockOnChange).toHaveBeenCalledWith('3:45 PM') + }) + + test('calls onChange when clicking Now button', () => { + const mockDate = new Date('2024-01-15T14:30:00') + jest.spyOn(globalThis, 'Date').mockImplementation(() => mockDate) + + render() + + const button = screen.getByRole('button') + fireEvent.click(button) + + const nowButton = screen.getByText('Now') + fireEvent.click(nowButton) + + expect(mockOnChange).toHaveBeenCalledWith('2:30 PM') + + jest.restoreAllMocks() + }) + + test('initializes picker with current value when opened', async () => { + render() + + const button = screen.getByRole('button') + fireEvent.click(button) + + await waitFor(() => { + const selectedHour = screen.getByText('3').closest('button') + expect(selectedHour).toHaveClass('bg-gray-100') + + const selectedMinute = screen.getByText('45').closest('button') + expect(selectedMinute).toHaveClass('bg-gray-100') + + const selectedPeriod = screen.getByText('PM').closest('button') + expect(selectedPeriod).toHaveClass('bg-gray-100') + }) + }) + + test('resets picker selection when reopening after closing without OK', async () => { + render() + + const button = screen.getByRole('button') + fireEvent.click(button) + + const hourButton = screen.getByText('3') + fireEvent.click(hourButton) + + const overlay = document.querySelector('.fixed.inset-0') + fireEvent.click(overlay!) + + fireEvent.click(button) + + await waitFor(() => { + const hourButtons = screen.getAllByText('11') + const selectedHourButton = hourButtons.find(btn => btn.closest('button')?.classList.contains('bg-gray-100')) + expect(selectedHourButton).toBeTruthy() + + const notSelectedHour = screen.getByText('3').closest('button') + expect(notSelectedHour).not.toHaveClass('bg-gray-100') + }) + }) + + test('handles 12 AM/PM correctly in Now button', () => { + const mockMidnight = new Date('2024-01-15T00:30:00') + jest.spyOn(globalThis, 'Date').mockImplementation(() => mockMidnight) + + render() + + const button = screen.getByRole('button') + fireEvent.click(button) + + const nowButton = screen.getByText('Now') + fireEvent.click(nowButton) + + expect(mockOnChange).toHaveBeenCalledWith('12:30 AM') + + jest.restoreAllMocks() + }) + + test('handles 12 PM correctly in Now button', () => { + const mockNoon = new Date('2024-01-15T12:30:00') + jest.spyOn(globalThis, 'Date').mockImplementation(() => mockNoon) + + render() + + const button = screen.getByRole('button') + fireEvent.click(button) + + const nowButton = screen.getByText('Now') + fireEvent.click(nowButton) + + expect(mockOnChange).toHaveBeenCalledWith('12:30 PM') + + jest.restoreAllMocks() + }) + + test('auto-scrolls to selected values when opened', async () => { + const mockScrollIntoView = jest.fn() + Element.prototype.scrollIntoView = mockScrollIntoView + + render() + + const button = screen.getByRole('button') + fireEvent.click(button) + + await waitFor(() => { + expect(mockScrollIntoView).toHaveBeenCalledWith({ + behavior: 'smooth', + block: 'center', + }) + }, { timeout: 200 }) + + mockScrollIntoView.mockRestore() + }) +}) diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/time-picker.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/time-picker.tsx new file mode 100644 index 0000000000..cacdbb01b8 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/components/time-picker.tsx @@ -0,0 +1,230 @@ +import React, { useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { RiTimeLine } from '@remixicon/react' + +const scrollbarHideStyles = { + scrollbarWidth: 'none' as const, + msOverflowStyle: 'none' as const, +} as React.CSSProperties + +type TimePickerProps = { + value?: string + onChange: (time: string) => void +} + +const TimePicker = ({ value = '11:30 AM', onChange }: TimePickerProps) => { + const { t } = useTranslation() + const [isOpen, setIsOpen] = useState(false) + const [selectedHour, setSelectedHour] = useState(11) + const [selectedMinute, setSelectedMinute] = useState(30) + const [selectedPeriod, setSelectedPeriod] = useState<'AM' | 'PM'>('AM') + const hourContainerRef = useRef(null) + const minuteContainerRef = useRef(null) + const periodContainerRef = useRef(null) + + React.useEffect(() => { + if (isOpen) { + if (value) { + const match = value.match(/(\d{1,2}):(\d{2})\s*(AM|PM)/) + if (match) { + setSelectedHour(Number.parseInt(match[1], 10)) + setSelectedMinute(Number.parseInt(match[2], 10)) + setSelectedPeriod(match[3] as 'AM' | 'PM') + } + } + else { + setSelectedHour(11) + setSelectedMinute(30) + setSelectedPeriod('AM') + } + } + }, [isOpen, value]) + + React.useEffect(() => { + if (isOpen) { + setTimeout(() => { + if (hourContainerRef.current) { + const selectedHourElement = hourContainerRef.current.querySelector('.bg-state-base-active') + if (selectedHourElement) + selectedHourElement.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } + + if (minuteContainerRef.current) { + const selectedMinuteElement = minuteContainerRef.current.querySelector('.bg-state-base-active') + if (selectedMinuteElement) + selectedMinuteElement.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } + + if (periodContainerRef.current) { + const selectedPeriodElement = periodContainerRef.current.querySelector('.bg-state-base-active') + if (selectedPeriodElement) + selectedPeriodElement.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } + }, 50) + } + }, [isOpen, selectedHour, selectedMinute, selectedPeriod]) + + const hours = Array.from({ length: 12 }, (_, i) => i + 1) + const minutes = Array.from({ length: 60 }, (_, i) => i) + const periods = ['AM', 'PM'] as const + + // Create padding elements to ensure bottom options can scroll to top + // Container shows 8 options (h-64), so we need 7 padding elements at bottom + const createBottomPadding = () => Array.from({ length: 7 }, (_, i) => ( +
+ )) + + const handleNow = () => { + const now = new Date() + const hour = now.getHours() + const minute = now.getMinutes() + const period = hour >= 12 ? 'PM' : 'AM' + let displayHour = hour + if (hour === 0) + displayHour = 12 + else if (hour > 12) + displayHour = hour - 12 + + const timeString = `${displayHour}:${minute.toString().padStart(2, '0')} ${period}` + onChange(timeString) + setIsOpen(false) + } + + const handleOK = () => { + const timeString = `${selectedHour}:${selectedMinute.toString().padStart(2, '0')} ${selectedPeriod}` + onChange(timeString) + setIsOpen(false) + } + + return ( +
+ + + {isOpen && ( +
+
+

{t('time.title.pickTime')}

+
+ +
+ +
+ {/* Hours */} +
+
+ {hours.map(hour => ( + + ))} + {createBottomPadding()} +
+
+ + {/* Minutes */} +
+
+ {minutes.map(minute => ( + + ))} + {createBottomPadding()} +
+
+ + {/* AM/PM */} +
+
+ {periods.map(period => ( + + ))} + {createBottomPadding()} +
+
+
+ + {/* Divider */} +
+ + {/* Buttons */} +
+ + +
+
+ )} + + {isOpen && ( +
setIsOpen(false)} + /> + )} +
+ ) +} + +export default TimePicker diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/weekday-selector.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/weekday-selector.tsx new file mode 100644 index 0000000000..03a36074bd --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/components/weekday-selector.tsx @@ -0,0 +1,53 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' + +type WeekdaySelectorProps = { + selectedDays: string[] + onChange: (days: string[]) => void +} + +const WeekdaySelector = ({ selectedDays, onChange }: WeekdaySelectorProps) => { + const { t } = useTranslation() + + const weekdays = [ + { key: 'sun', label: 'Sun' }, + { key: 'mon', label: 'Mon' }, + { key: 'tue', label: 'Tue' }, + { key: 'wed', label: 'Wed' }, + { key: 'thu', label: 'Thu' }, + { key: 'fri', label: 'Fri' }, + { key: 'sat', label: 'Sat' }, + ] + + const selectedDay = selectedDays.length > 0 ? selectedDays[0] : 'sun' + + const handleDaySelect = (dayKey: string) => { + onChange([dayKey]) + } + + return ( +
+ +
+ {weekdays.map(day => ( + + ))} +
+
+ ) +} + +export default WeekdaySelector diff --git a/web/app/components/workflow/nodes/trigger-schedule/default.ts b/web/app/components/workflow/nodes/trigger-schedule/default.ts new file mode 100644 index 0000000000..3a9ead48a9 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/default.ts @@ -0,0 +1,35 @@ +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' + +const nodeDefault: NodeDefault = { + defaultValue: { + mode: 'visual', + frequency: 'daily', + cron_expression: '', + visual_config: { + time: '11:30 AM', + weekdays: ['sun'], + }, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + enabled: true, + }, + getAvailablePrevNodes(_isChatMode: boolean) { + return [] + }, + getAvailableNextNodes(isChatMode: boolean) { + const nodes = isChatMode + ? ALL_CHAT_AVAILABLE_BLOCKS + : ALL_COMPLETION_AVAILABLE_BLOCKS.filter(type => type !== BlockEnum.End) + return nodes.filter(type => type !== BlockEnum.Start) + }, + checkValid(_payload: ScheduleTriggerNodeType, _t: any) { + return { + isValid: true, + errorMessage: '', + } + }, +} + +export default nodeDefault diff --git a/web/app/components/workflow/nodes/trigger-schedule/node.tsx b/web/app/components/workflow/nodes/trigger-schedule/node.tsx new file mode 100644 index 0000000000..e9c1f34e2c --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/node.tsx @@ -0,0 +1,27 @@ +import type { FC } from 'react' +import React from 'react' +import { useTranslation } from 'react-i18next' +import type { ScheduleTriggerNodeType } from './types' +import type { NodeProps } from '@/app/components/workflow/types' +import { getNextExecutionTime } from './utils/execution-time-calculator' + +const i18nPrefix = 'workflow.nodes.triggerSchedule' + +const Node: FC> = ({ + data, +}) => { + const { t } = useTranslation() + + return ( +
+
+ {t(`${i18nPrefix}.nextExecutionTime`)} +
+
+ {getNextExecutionTime(data)} +
+
+ ) +} + +export default React.memo(Node) diff --git a/web/app/components/workflow/nodes/trigger-schedule/panel.tsx b/web/app/components/workflow/nodes/trigger-schedule/panel.tsx new file mode 100644 index 0000000000..6465b47afd --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/panel.tsx @@ -0,0 +1,166 @@ +import type { FC } from 'react' +import React from 'react' +import { useTranslation } from 'react-i18next' +import type { ScheduleTriggerNodeType } from './types' +import Field from '@/app/components/workflow/nodes/_base/components/field' +import type { NodePanelProps } from '@/app/components/workflow/types' +import ModeToggle from './components/mode-toggle' +import FrequencySelector from './components/frequency-selector' +import WeekdaySelector from './components/weekday-selector' +import TimePicker from './components/time-picker' +import DateTimePicker from './components/date-time-picker' +import NextExecutionTimes from './components/next-execution-times' +import ExecuteNowButton from './components/execute-now-button' +import RecurConfig from './components/recur-config' +import MonthlyDaysSelector from './components/monthly-days-selector' +import Input from '@/app/components/base/input' +import useConfig from './use-config' + +const i18nPrefix = 'workflow.nodes.triggerSchedule' + +const Panel: FC> = ({ + id, + data, +}) => { + const { t } = useTranslation() + const { + inputs, + setInputs, + handleModeChange, + handleFrequencyChange, + handleCronExpressionChange, + handleWeekdaysChange, + handleTimeChange, + handleRecurEveryChange, + handleRecurUnitChange, + } = useConfig(id, data) + + const handleExecuteNow = () => { + // TODO: Implement execute now functionality + console.log('Execute now clicked') + } + + return ( +
+
+ + } + > +
+ + {inputs.mode === 'visual' && ( +
+
+
+ + +
+
+ + {inputs.frequency === 'hourly' || inputs.frequency === 'once' ? ( + { + const newInputs = { + ...inputs, + visual_config: { + ...inputs.visual_config, + datetime, + }, + } + setInputs(newInputs) + }} + /> + ) : ( + + )} +
+
+ + {inputs.frequency === 'weekly' && ( + + )} + + {inputs.frequency === 'hourly' && ( + + )} + + {inputs.frequency === 'monthly' && ( + { + const newInputs = { + ...inputs, + visual_config: { + ...inputs.visual_config, + monthly_day: day, + }, + } + setInputs(newInputs) + }} + /> + )} +
+ )} + + {inputs.mode === 'cron' && ( +
+
+ + handleCronExpressionChange(e.target.value)} + placeholder="0 0 * * *" + className="font-mono" + /> +
+
+ Enter cron expression (minute hour day month weekday) +
+
+ )} +
+
+ +
+ + + +
+ +
+
+
+ ) +} + +export default React.memo(Panel) diff --git a/web/app/components/workflow/nodes/trigger-schedule/types.ts b/web/app/components/workflow/nodes/trigger-schedule/types.ts new file mode 100644 index 0000000000..748d3ab96e --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/types.ts @@ -0,0 +1,24 @@ +import type { CommonNodeType } from '@/app/components/workflow/types' + +export type ScheduleMode = 'visual' | 'cron' + +export type ScheduleFrequency = 'hourly' | 'daily' | 'weekly' | 'monthly' | 'once' + +export type VisualConfig = { + time?: string + datetime?: string + days?: number[] + weekdays?: string[] + recur_every?: number + recur_unit?: 'hours' | 'minutes' + monthly_day?: number | 'last' +} + +export type ScheduleTriggerNodeType = CommonNodeType & { + mode: ScheduleMode + frequency: ScheduleFrequency + cron_expression?: string + visual_config?: VisualConfig + timezone: string + enabled: boolean +} diff --git a/web/app/components/workflow/nodes/trigger-schedule/use-config.ts b/web/app/components/workflow/nodes/trigger-schedule/use-config.ts new file mode 100644 index 0000000000..1bc29b18b1 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/use-config.ts @@ -0,0 +1,106 @@ +import { useCallback } from 'react' +import type { ScheduleFrequency, ScheduleMode, ScheduleTriggerNodeType } from './types' +import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' +import { useNodesReadOnly } from '@/app/components/workflow/hooks' + +const useConfig = (id: string, payload: ScheduleTriggerNodeType) => { + const { nodesReadOnly: readOnly } = useNodesReadOnly() + + const defaultPayload = { + ...payload, + mode: payload.mode || 'visual', + frequency: payload.frequency || 'daily', + visual_config: { + time: '11:30 AM', + weekdays: ['sun'], + ...payload.visual_config, + }, + timezone: payload.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone, + enabled: payload.enabled !== undefined ? payload.enabled : true, + } + + const { inputs, setInputs } = useNodeCrud(id, defaultPayload) + + const handleModeChange = useCallback((mode: ScheduleMode) => { + const newInputs = { + ...inputs, + mode, + } + setInputs(newInputs) + }, [inputs, setInputs]) + + const handleFrequencyChange = useCallback((frequency: ScheduleFrequency) => { + const newInputs = { + ...inputs, + frequency, + } + setInputs(newInputs) + }, [inputs, setInputs]) + + const handleCronExpressionChange = useCallback((value: string) => { + const newInputs = { + ...inputs, + cron_expression: value, + } + setInputs(newInputs) + }, [inputs, setInputs]) + + const handleWeekdaysChange = useCallback((weekdays: string[]) => { + const newInputs = { + ...inputs, + visual_config: { + ...inputs.visual_config, + weekdays, + }, + } + setInputs(newInputs) + }, [inputs, setInputs]) + + const handleTimeChange = useCallback((time: string) => { + const newInputs = { + ...inputs, + visual_config: { + ...inputs.visual_config, + time, + }, + } + setInputs(newInputs) + }, [inputs, setInputs]) + + const handleRecurEveryChange = useCallback((recur_every: number) => { + const newInputs = { + ...inputs, + visual_config: { + ...inputs.visual_config, + recur_every, + }, + } + setInputs(newInputs) + }, [inputs, setInputs]) + + const handleRecurUnitChange = useCallback((recur_unit: 'hours' | 'minutes') => { + const newInputs = { + ...inputs, + visual_config: { + ...inputs.visual_config, + recur_unit, + }, + } + setInputs(newInputs) + }, [inputs, setInputs]) + + return { + readOnly, + inputs, + setInputs, + handleModeChange, + handleFrequencyChange, + handleCronExpressionChange, + handleWeekdaysChange, + handleTimeChange, + handleRecurEveryChange, + handleRecurUnitChange, + } +} + +export default useConfig diff --git a/web/app/components/workflow/nodes/trigger-schedule/utils/cron-parser.spec.ts b/web/app/components/workflow/nodes/trigger-schedule/utils/cron-parser.spec.ts new file mode 100644 index 0000000000..b6545f39c5 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/utils/cron-parser.spec.ts @@ -0,0 +1,233 @@ +import { isValidCronExpression, parseCronExpression } from './cron-parser' + +describe('cron-parser', () => { + describe('isValidCronExpression', () => { + test('validates correct cron expressions', () => { + expect(isValidCronExpression('15 10 1 * *')).toBe(true) + expect(isValidCronExpression('0 0 * * 0')).toBe(true) + expect(isValidCronExpression('*/5 * * * *')).toBe(true) + expect(isValidCronExpression('0 9-17 * * 1-5')).toBe(true) + expect(isValidCronExpression('30 14 * * 1')).toBe(true) + expect(isValidCronExpression('0 0 1,15 * *')).toBe(true) + }) + + test('rejects invalid cron expressions', () => { + expect(isValidCronExpression('')).toBe(false) + expect(isValidCronExpression('15 10 1')).toBe(false) // Not enough fields + expect(isValidCronExpression('15 10 1 * * *')).toBe(false) // Too many fields + expect(isValidCronExpression('60 10 1 * *')).toBe(false) // Invalid minute + expect(isValidCronExpression('15 25 1 * *')).toBe(false) // Invalid hour + expect(isValidCronExpression('15 10 32 * *')).toBe(false) // Invalid day + expect(isValidCronExpression('15 10 1 13 *')).toBe(false) // Invalid month + expect(isValidCronExpression('15 10 1 * 7')).toBe(false) // Invalid day of week + }) + + test('handles edge cases', () => { + expect(isValidCronExpression(' 15 10 1 * * ')).toBe(true) // Whitespace + expect(isValidCronExpression('0 0 29 2 *')).toBe(true) // Feb 29 (valid in leap years) + expect(isValidCronExpression('59 23 31 12 6')).toBe(true) // Max values + }) + }) + + describe('parseCronExpression', () => { + beforeEach(() => { + // Mock current time to make tests deterministic + jest.useFakeTimers() + jest.setSystemTime(new Date('2024-01-15T10:00:00Z')) + }) + + afterEach(() => { + jest.useRealTimers() + }) + + test('parses monthly expressions correctly', () => { + const result = parseCronExpression('15 10 1 * *') // 1st day of every month at 10:15 + + expect(result).toHaveLength(5) + expect(result[0].getDate()).toBe(1) // February 1st + expect(result[0].getHours()).toBe(10) + expect(result[0].getMinutes()).toBe(15) + expect(result[1].getDate()).toBe(1) // March 1st + expect(result[2].getDate()).toBe(1) // April 1st + }) + + test('parses weekly expressions correctly', () => { + const result = parseCronExpression('30 14 * * 1') // Every Monday at 14:30 + + expect(result).toHaveLength(5) + // Should find next 5 Mondays + result.forEach((date) => { + expect(date.getDay()).toBe(1) // Monday + expect(date.getHours()).toBe(14) + expect(date.getMinutes()).toBe(30) + }) + }) + + test('parses daily expressions correctly', () => { + const result = parseCronExpression('0 9 * * *') // Every day at 9:00 + + expect(result).toHaveLength(5) + result.forEach((date) => { + expect(date.getHours()).toBe(9) + expect(date.getMinutes()).toBe(0) + }) + + // Should be consecutive days (starting from tomorrow since current time is 10:00) + for (let i = 1; i < result.length; i++) { + const prevDate = new Date(result[i - 1]) + const currDate = new Date(result[i]) + const dayDiff = (currDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24) + expect(dayDiff).toBe(1) + } + }) + + test('handles complex cron expressions with ranges', () => { + const result = parseCronExpression('0 9-17 * * 1-5') // Weekdays, 9-17 hours + + expect(result).toHaveLength(5) + result.forEach((date) => { + expect(date.getDay()).toBeGreaterThanOrEqual(1) // Monday + expect(date.getDay()).toBeLessThanOrEqual(5) // Friday + expect(date.getHours()).toBeGreaterThanOrEqual(9) + expect(date.getHours()).toBeLessThanOrEqual(17) + expect(date.getMinutes()).toBe(0) + }) + }) + + test('handles step expressions', () => { + const result = parseCronExpression('*/15 * * * *') // Every 15 minutes + + expect(result).toHaveLength(5) + result.forEach((date) => { + expect(date.getMinutes() % 15).toBe(0) + }) + }) + + test('handles list expressions', () => { + const result = parseCronExpression('0 0 1,15 * *') // 1st and 15th of each month + + expect(result).toHaveLength(5) + result.forEach((date) => { + expect([1, 15]).toContain(date.getDate()) + expect(date.getHours()).toBe(0) + expect(date.getMinutes()).toBe(0) + }) + }) + + test('handles expressions that span multiple months', () => { + // Test with an expression that might not have many matches in current month + const result = parseCronExpression('0 12 29 * *') // 29th of each month at noon + + expect(result.length).toBeGreaterThan(0) + expect(result.length).toBeLessThanOrEqual(5) + result.forEach((date) => { + expect(date.getDate()).toBe(29) + expect(date.getHours()).toBe(12) + expect(date.getMinutes()).toBe(0) + }) + }) + + test('returns empty array for invalid expressions', () => { + expect(parseCronExpression('')).toEqual([]) + expect(parseCronExpression('invalid')).toEqual([]) + expect(parseCronExpression('60 10 1 * *')).toEqual([]) + expect(parseCronExpression('15 25 1 * *')).toEqual([]) + }) + + test('handles edge case: February 29th in non-leap years', () => { + // Set to a non-leap year + jest.setSystemTime(new Date('2023-01-15T10:00:00Z')) + + const result = parseCronExpression('0 12 29 2 *') // Feb 29th at noon + + // Should return empty or skip 2023 and find 2024 + if (result.length > 0) { + result.forEach((date) => { + expect(date.getMonth()).toBe(1) // February + expect(date.getDate()).toBe(29) + // Should be in a leap year + const year = date.getFullYear() + expect(year % 4).toBe(0) + }) + } + }) + + test('sorts results chronologically', () => { + const result = parseCronExpression('0 */6 * * *') // Every 6 hours + + expect(result).toHaveLength(5) + for (let i = 1; i < result.length; i++) + expect(result[i].getTime()).toBeGreaterThan(result[i - 1].getTime()) + }) + + test('excludes past times', () => { + // Set current time to 15:30 + jest.setSystemTime(new Date('2024-01-15T15:30:00Z')) + + const result = parseCronExpression('0 10 * * *') // Daily at 10:00 + + expect(result).toHaveLength(5) + result.forEach((date) => { + expect(date.getTime()).toBeGreaterThan(Date.now()) + }) + + // First result should be tomorrow since today's 10:00 has passed + expect(result[0].getDate()).toBe(16) + }) + + test('handles midnight expressions correctly', () => { + const result = parseCronExpression('0 0 * * *') // Daily at midnight + + expect(result).toHaveLength(5) + result.forEach((date) => { + expect(date.getHours()).toBe(0) + expect(date.getMinutes()).toBe(0) + }) + }) + + test('handles year boundary correctly', () => { + // Set to end of December + jest.setSystemTime(new Date('2024-12-30T10:00:00Z')) + + const result = parseCronExpression('0 12 1 * *') // 1st of every month at noon + + expect(result).toHaveLength(5) + // Should include January 1st of next year + const nextYear = result.find(date => date.getFullYear() === 2025) + expect(nextYear).toBeDefined() + if (nextYear) { + expect(nextYear.getMonth()).toBe(0) // January + expect(nextYear.getDate()).toBe(1) + } + }) + }) + + describe('performance tests', () => { + test('performs well for complex expressions', () => { + const start = performance.now() + + // Test multiple complex expressions + const expressions = [ + '*/5 9-17 * * 1-5', // Every 5 minutes, weekdays, business hours + '0 */2 1,15 * *', // Every 2 hours on 1st and 15th + '30 14 * * 1,3,5', // Mon, Wed, Fri at 14:30 + '15,45 8-18 * * 1-5', // 15 and 45 minutes past the hour, weekdays + ] + + expressions.forEach((expr) => { + const result = parseCronExpression(expr) + expect(result).toHaveLength(5) + }) + + // Test quarterly expression separately (may return fewer than 5 results) + const quarterlyResult = parseCronExpression('0 0 1 */3 *') // First day of every 3rd month + expect(quarterlyResult.length).toBeGreaterThan(0) + expect(quarterlyResult.length).toBeLessThanOrEqual(5) + + const end = performance.now() + + // Should complete within reasonable time (less than 100ms for all expressions) + expect(end - start).toBeLessThan(100) + }) + }) +}) 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 new file mode 100644 index 0000000000..e4316ecbd2 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/utils/cron-parser.ts @@ -0,0 +1,237 @@ +const matchesField = (value: number, pattern: string, min: number, max: number): boolean => { + if (pattern === '*') return true + + if (pattern.includes(',')) + return pattern.split(',').some(p => matchesField(value, p.trim(), min, max)) + + if (pattern.includes('/')) { + const [range, step] = pattern.split('/') + const stepValue = Number.parseInt(step, 10) + if (Number.isNaN(stepValue)) return false + + if (range === '*') { + return value % stepValue === min % stepValue + } + else { + const rangeStart = Number.parseInt(range, 10) + if (Number.isNaN(rangeStart)) return false + return value >= rangeStart && (value - rangeStart) % stepValue === 0 + } + } + + if (pattern.includes('-')) { + const [start, end] = pattern.split('-').map(p => Number.parseInt(p.trim(), 10)) + if (Number.isNaN(start) || Number.isNaN(end)) return false + return value >= start && value <= end + } + + const numValue = Number.parseInt(pattern, 10) + if (Number.isNaN(numValue)) return false + return value === numValue +} + +const expandCronField = (field: string, min: number, max: number): number[] => { + if (field === '*') + return Array.from({ length: max - min + 1 }, (_, i) => min + i) + + if (field.includes(',')) + return field.split(',').flatMap(p => expandCronField(p.trim(), min, max)) + + if (field.includes('/')) { + const [range, step] = field.split('/') + const stepValue = Number.parseInt(step, 10) + if (Number.isNaN(stepValue)) return [] + + const baseValues = range === '*' ? [min] : expandCronField(range, min, max) + const result: number[] = [] + + for (let start = baseValues[0]; start <= max; start += stepValue) { + if (start >= min && start <= max) + result.push(start) + } + return result + } + + if (field.includes('-')) { + const [start, end] = field.split('-').map(p => Number.parseInt(p.trim(), 10)) + if (Number.isNaN(start) || Number.isNaN(end)) return [] + + const result: number[] = [] + for (let i = start; i <= end && i <= max; i++) + if (i >= min) result.push(i) + + return result + } + + const numValue = Number.parseInt(field, 10) + return !Number.isNaN(numValue) && numValue >= min && numValue <= max ? [numValue] : [] +} + +const matchesCron = ( + date: Date, + minute: string, + hour: string, + dayOfMonth: string, + month: string, + dayOfWeek: string, +): boolean => { + const currentMinute = date.getMinutes() + const currentHour = date.getHours() + const currentDay = date.getDate() + const currentMonth = date.getMonth() + 1 + const currentDayOfWeek = date.getDay() + + // Basic time matching + if (!matchesField(currentMinute, minute, 0, 59)) return false + if (!matchesField(currentHour, hour, 0, 23)) return false + if (!matchesField(currentMonth, month, 1, 12)) return false + + // Day matching logic: if both dayOfMonth and dayOfWeek are specified (not *), + // the cron should match if EITHER condition is true (OR logic) + const dayOfMonthSpecified = dayOfMonth !== '*' + const dayOfWeekSpecified = dayOfWeek !== '*' + + if (dayOfMonthSpecified && dayOfWeekSpecified) { + // If both are specified, match if either matches + return matchesField(currentDay, dayOfMonth, 1, 31) + || matchesField(currentDayOfWeek, dayOfWeek, 0, 6) + } + else if (dayOfMonthSpecified) { + // Only day of month specified + return matchesField(currentDay, dayOfMonth, 1, 31) + } + else if (dayOfWeekSpecified) { + // Only day of week specified + return matchesField(currentDayOfWeek, dayOfWeek, 0, 6) + } + else { + // Both are *, matches any day + return true + } +} + +export const parseCronExpression = (cronExpression: string): Date[] => { + if (!cronExpression || cronExpression.trim() === '') + return [] + + const parts = cronExpression.trim().split(/\s+/) + if (parts.length !== 5) + return [] + + const [minute, hour, dayOfMonth, month, dayOfWeek] = parts + + try { + const nextTimes: Date[] = [] + const now = new Date() + + // Start from next minute + const startTime = new Date(now) + startTime.setMinutes(startTime.getMinutes() + 1) + startTime.setSeconds(0, 0) + + // For monthly expressions (like "15 10 1 * *"), we need to check more months + // For weekly expressions, we need to check more weeks + // Use a smarter approach: check up to 12 months for monthly patterns + const isMonthlyPattern = dayOfMonth !== '*' && dayOfWeek === '*' + const isWeeklyPattern = dayOfMonth === '*' && dayOfWeek !== '*' + + let searchMonths = 12 + if (isWeeklyPattern) searchMonths = 3 // 3 months should cover 12+ weeks + else if (!isMonthlyPattern) searchMonths = 2 // For daily/hourly patterns + + // Check across multiple months + for (let monthOffset = 0; monthOffset < searchMonths && nextTimes.length < 5; monthOffset++) { + const checkMonth = new Date(startTime.getFullYear(), startTime.getMonth() + monthOffset, 1) + + // Get the number of days in this month + const daysInMonth = new Date(checkMonth.getFullYear(), checkMonth.getMonth() + 1, 0).getDate() + + // Check each day in this month + for (let day = 1; day <= daysInMonth && nextTimes.length < 5; day++) { + const checkDate = new Date(checkMonth.getFullYear(), checkMonth.getMonth(), day) + + // For each day, check the specific hour and minute from cron + // This is more efficient than checking all hours/minutes + if (minute !== '*' && hour !== '*') { + // Extract specific minute and hour values + const minuteValues = expandCronField(minute, 0, 59) + const hourValues = expandCronField(hour, 0, 23) + + for (const h of hourValues) { + for (const m of minuteValues) { + checkDate.setHours(h, m, 0, 0) + + // Skip if this time is before our start time + if (checkDate <= now) continue + + if (matchesCron(checkDate, minute, hour, dayOfMonth, month, dayOfWeek)) + nextTimes.push(new Date(checkDate)) + } + } + } + else { + // Fallback for complex expressions with wildcards + for (let h = 0; h < 24 && nextTimes.length < 5; h++) { + for (let m = 0; m < 60 && nextTimes.length < 5; m++) { + checkDate.setHours(h, m, 0, 0) + + if (checkDate <= now) continue + + if (matchesCron(checkDate, minute, hour, dayOfMonth, month, dayOfWeek)) + nextTimes.push(new Date(checkDate)) + } + } + } + } + } + + return nextTimes.sort((a, b) => a.getTime() - b.getTime()).slice(0, 5) + } + catch { + return [] + } +} + +const isValidCronField = (field: string, min: number, max: number): boolean => { + if (field === '*') return true + + if (field.includes(',')) + return field.split(',').every(p => isValidCronField(p.trim(), min, max)) + + if (field.includes('/')) { + const [range, step] = field.split('/') + const stepValue = Number.parseInt(step, 10) + if (Number.isNaN(stepValue) || stepValue <= 0) return false + + if (range === '*') return true + return isValidCronField(range, min, max) + } + + if (field.includes('-')) { + const [start, end] = field.split('-').map(p => Number.parseInt(p.trim(), 10)) + if (Number.isNaN(start) || Number.isNaN(end)) return false + return start >= min && end <= max && start <= end + } + + const numValue = Number.parseInt(field, 10) + return !Number.isNaN(numValue) && numValue >= min && numValue <= max +} + +export const isValidCronExpression = (cronExpression: string): boolean => { + if (!cronExpression || cronExpression.trim() === '') + return false + + const parts = cronExpression.trim().split(/\s+/) + if (parts.length !== 5) + return false + + const [minute, hour, dayOfMonth, month, dayOfWeek] = parts + + return ( + isValidCronField(minute, 0, 59) + && isValidCronField(hour, 0, 23) + && isValidCronField(dayOfMonth, 1, 31) + && isValidCronField(month, 1, 12) + && isValidCronField(dayOfWeek, 0, 6) + ) +} diff --git a/web/app/components/workflow/nodes/trigger-schedule/utils/execution-time-calculator.spec.ts b/web/app/components/workflow/nodes/trigger-schedule/utils/execution-time-calculator.spec.ts new file mode 100644 index 0000000000..3e05c96255 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/utils/execution-time-calculator.spec.ts @@ -0,0 +1,795 @@ +import { formatExecutionTime, getDefaultDateTime, getFormattedExecutionTimes, getNextExecutionTime, getNextExecutionTimes } from './execution-time-calculator' +import type { ScheduleTriggerNodeType } from '../types' + +const createMockData = (overrides: Partial = {}): ScheduleTriggerNodeType => ({ + id: 'test-node', + type: 'schedule-trigger', + mode: 'visual', + frequency: 'daily', + visual_config: { + time: '11:30 AM', + weekdays: ['sun'], + recur_every: 1, + recur_unit: 'hours', + }, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, // Use system timezone for consistent tests + enabled: true, + ...overrides, +}) + +describe('execution-time-calculator', () => { + beforeEach(() => { + jest.useFakeTimers() + jest.setSystemTime(new Date(2024, 0, 15, 10, 0, 0)) // Local time: 2024-01-15 10:00:00 + }) + + afterEach(() => { + jest.useRealTimers() + }) + + describe('formatExecutionTime', () => { + test('formats time with weekday by default', () => { + const date = new Date(2024, 0, 16, 14, 30) + const result = formatExecutionTime(date) + + expect(result).toBe('Tue, January 16, 2024 2:30 PM') + }) + + test('formats time without weekday when specified', () => { + const date = new Date(2024, 0, 16, 14, 30) + const result = formatExecutionTime(date, false) + + expect(result).toBe('January 16, 2024 2:30 PM') + }) + + test('handles morning times correctly', () => { + const date = new Date(2024, 0, 16, 9, 15) + const result = formatExecutionTime(date) + + expect(result).toBe('Tue, January 16, 2024 9:15 AM') + }) + + test('handles midnight correctly', () => { + const date = new Date(2024, 0, 16, 0, 0) + const result = formatExecutionTime(date) + + expect(result).toBe('Tue, January 16, 2024 12:00 AM') + }) + }) + + describe('getNextExecutionTimes - daily frequency', () => { + test('calculates next 5 daily executions', () => { + const data = createMockData({ + frequency: 'daily', + visual_config: { time: '2:30 PM' }, + }) + + const result = getNextExecutionTimes(data, 5) + + expect(result).toHaveLength(5) + expect(result[0].getHours()).toBe(14) + expect(result[0].getMinutes()).toBe(30) + expect(result[1].getDate()).toBe(result[0].getDate() + 1) + }) + + test('handles past time by moving to next day', () => { + jest.setSystemTime(new Date(2024, 0, 15, 15, 0, 0)) // 3:00 PM local time + + const data = createMockData({ + frequency: 'daily', + visual_config: { time: '2:30 PM' }, + }) + + const result = getNextExecutionTimes(data, 1) + + expect(result[0].getDate()).toBe(16) + }) + + test('handles AM/PM conversion correctly', () => { + const data = createMockData({ + frequency: 'daily', + visual_config: { time: '11:30 PM' }, + }) + + const result = getNextExecutionTimes(data, 1) + + expect(result[0].getHours()).toBe(23) + expect(result[0].getMinutes()).toBe(30) + }) + + test('handles 12 AM correctly', () => { + const data = createMockData({ + frequency: 'daily', + visual_config: { time: '12:00 AM' }, + }) + + const result = getNextExecutionTimes(data, 1) + + expect(result[0].getHours()).toBe(0) + }) + + test('handles 12 PM correctly', () => { + const data = createMockData({ + frequency: 'daily', + visual_config: { time: '12:00 PM' }, + }) + + const result = getNextExecutionTimes(data, 1) + + expect(result[0].getHours()).toBe(12) + }) + }) + + describe('getNextExecutionTimes - weekly frequency', () => { + test('calculates next 5 weekly executions for Sunday', () => { + const data = createMockData({ + frequency: 'weekly', + visual_config: { + time: '2:30 PM', + weekdays: ['sun'], + }, + }) + + const result = getNextExecutionTimes(data, 5) + + expect(result).toHaveLength(5) + result.forEach((date) => { + expect(date.getDay()).toBe(0) + expect(date.getHours()).toBe(14) + expect(date.getMinutes()).toBe(30) + }) + }) + + test('calculates next execution for Monday from Monday', () => { + jest.setSystemTime(new Date(2024, 0, 15, 10, 0)) + + const data = createMockData({ + frequency: 'weekly', + visual_config: { + time: '2:30 PM', + weekdays: ['mon'], + }, + }) + + const result = getNextExecutionTimes(data, 2) + + expect(result[0].getDate()).toBe(15) + expect(result[1].getDate()).toBe(22) + }) + + test('moves to next week when current day time has passed', () => { + jest.setSystemTime(new Date(2024, 0, 15, 15, 0, 0)) // Monday 3:00 PM local time + + const data = createMockData({ + frequency: 'weekly', + visual_config: { + time: '2:30 PM', + weekdays: ['mon'], + }, + }) + + const result = getNextExecutionTimes(data, 1) + + expect(result[0].getDate()).toBe(22) + }) + + test('handles different weekdays correctly', () => { + const data = createMockData({ + frequency: 'weekly', + visual_config: { + time: '9:00 AM', + weekdays: ['fri'], + }, + }) + + const result = getNextExecutionTimes(data, 1) + + expect(result[0].getDay()).toBe(5) + }) + }) + + describe('getNextExecutionTimes - hourly frequency', () => { + test('calculates hourly intervals correctly', () => { + const startTime = new Date(2024, 0, 15, 12, 0, 0) // Local time 12:00 PM + + const data = createMockData({ + frequency: 'hourly', + visual_config: { + datetime: startTime.toISOString(), + recur_every: 2, + recur_unit: 'hours', + }, + }) + + const result = getNextExecutionTimes(data, 3) + + expect(result).toHaveLength(3) + expect(result[0].getTime() - startTime.getTime()).toBe(2 * 60 * 60 * 1000) + expect(result[1].getTime() - startTime.getTime()).toBe(4 * 60 * 60 * 1000) + expect(result[2].getTime() - startTime.getTime()).toBe(6 * 60 * 60 * 1000) + }) + + test('calculates minute intervals correctly', () => { + const startTime = new Date(2024, 0, 15, 12, 0, 0) // Local time 12:00 PM + + const data = createMockData({ + frequency: 'hourly', + visual_config: { + datetime: startTime.toISOString(), + recur_every: 30, + recur_unit: 'minutes', + }, + }) + + const result = getNextExecutionTimes(data, 3) + + expect(result).toHaveLength(3) + expect(result[0].getTime() - startTime.getTime()).toBe(30 * 60 * 1000) + expect(result[1].getTime() - startTime.getTime()).toBe(60 * 60 * 1000) + }) + + test('handles past start time by calculating next interval', () => { + jest.setSystemTime(new Date(2024, 0, 15, 14, 30, 0)) // Local time 2:30 PM + const startTime = new Date(2024, 0, 15, 12, 0, 0) // Local time 12:00 PM + + const data = createMockData({ + frequency: 'hourly', + visual_config: { + datetime: startTime.toISOString(), + recur_every: 1, + recur_unit: 'hours', + }, + }) + + const result = getNextExecutionTimes(data, 2) + + expect(result[0].getHours()).toBe(15) + expect(result[1].getHours()).toBe(16) + }) + + test('uses current time as default start time', () => { + const data = createMockData({ + frequency: 'hourly', + visual_config: { + recur_every: 1, + recur_unit: 'hours', + }, + }) + + const result = getNextExecutionTimes(data, 1) + + expect(result[0].getTime()).toBeGreaterThan(Date.now()) + }) + + test('minute intervals should not have duplicates when recur_every changes', () => { + const startTime = new Date(2024, 0, 15, 12, 0, 0) + + // Test with recur_every = 2 minutes + const data2 = createMockData({ + frequency: 'hourly', + visual_config: { + datetime: startTime.toISOString(), + recur_every: 2, + recur_unit: 'minutes', + }, + }) + + const result2 = getNextExecutionTimes(data2, 5) + + // Check for no duplicates in result2 + const timestamps2 = result2.map(date => date.getTime()) + const uniqueTimestamps2 = new Set(timestamps2) + expect(timestamps2.length).toBe(uniqueTimestamps2.size) + + // Check intervals are correct for 2-minute intervals + for (let i = 1; i < result2.length; i++) { + const timeDiff = result2[i].getTime() - result2[i - 1].getTime() + expect(timeDiff).toBe(2 * 60 * 1000) // 2 minutes in milliseconds + } + }) + + test('hourly intervals should handle recur_every changes correctly', () => { + const startTime = new Date(2024, 0, 15, 12, 0, 0) + + // Test with recur_every = 3 hours + const data = createMockData({ + frequency: 'hourly', + visual_config: { + datetime: startTime.toISOString(), + recur_every: 3, + recur_unit: 'hours', + }, + }) + + const result = getNextExecutionTimes(data, 4) + + // Check for no duplicates + const timestamps = result.map(date => date.getTime()) + const uniqueTimestamps = new Set(timestamps) + expect(timestamps.length).toBe(uniqueTimestamps.size) + + // Check intervals are correct for 3-hour intervals + for (let i = 1; i < result.length; i++) { + const timeDiff = result[i].getTime() - result[i - 1].getTime() + expect(timeDiff).toBe(3 * 60 * 60 * 1000) // 3 hours in milliseconds + } + }) + }) + + describe('getNextExecutionTimes - cron mode', () => { + test('uses cron parser for cron expressions', () => { + const data = createMockData({ + mode: 'cron', + cron_expression: '0 12 * * *', + }) + + const result = getNextExecutionTimes(data, 3) + + expect(result).toHaveLength(3) + result.forEach((date) => { + expect(date.getHours()).toBe(12) + expect(date.getMinutes()).toBe(0) + }) + }) + + test('returns empty array for invalid cron expression', () => { + const data = createMockData({ + mode: 'cron', + cron_expression: 'invalid', + }) + + const result = getNextExecutionTimes(data, 5) + + expect(result).toEqual([]) + }) + + test('returns empty array for missing cron expression', () => { + const data = createMockData({ + mode: 'cron', + cron_expression: '', + }) + + const result = getNextExecutionTimes(data, 5) + + expect(result).toEqual([]) + }) + }) + + describe('getNextExecutionTimes - once frequency', () => { + test('returns selected datetime for once frequency', () => { + const selectedTime = new Date(2024, 0, 20, 15, 30, 0) // January 20, 2024 3:30 PM + const data = createMockData({ + frequency: 'once', + visual_config: { + datetime: selectedTime.toISOString(), + }, + }) + + const result = getNextExecutionTimes(data, 5) + + expect(result).toHaveLength(1) + expect(result[0].getTime()).toBe(selectedTime.getTime()) + }) + + test('returns empty array when no datetime selected for once frequency', () => { + const data = createMockData({ + frequency: 'once', + visual_config: {}, + }) + + const result = getNextExecutionTimes(data, 5) + + expect(result).toEqual([]) + }) + }) + + describe('getNextExecutionTimes - fallback behavior', () => { + test('handles unknown frequency by returning next days', () => { + const data = createMockData({ + frequency: 'unknown' as any, + }) + + const result = getNextExecutionTimes(data, 3) + + expect(result).toHaveLength(3) + expect(result[0].getDate()).toBe(16) + expect(result[1].getDate()).toBe(17) + expect(result[2].getDate()).toBe(18) + }) + }) + + describe('getFormattedExecutionTimes', () => { + test('formats daily execution times without weekday', () => { + const data = createMockData({ + frequency: 'daily', + visual_config: { time: '2:30 PM' }, + }) + + const result = getFormattedExecutionTimes(data, 2) + + expect(result).toHaveLength(2) + expect(result[0]).not.toMatch(/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/) + expect(result[0]).toMatch(/January \d+, 2024 2:30 PM/) + }) + + test('formats weekly execution times with weekday', () => { + const data = createMockData({ + frequency: 'weekly', + visual_config: { + time: '2:30 PM', + weekdays: ['sun'], + }, + }) + + const result = getFormattedExecutionTimes(data, 2) + + expect(result).toHaveLength(2) + expect(result[0]).toMatch(/^Sun, January \d+, 2024 2:30 PM/) + }) + + test('formats hourly execution times without weekday', () => { + const data = createMockData({ + frequency: 'hourly', + visual_config: { + datetime: new Date(2024, 0, 16, 14, 0, 0).toISOString(), // Local time 2:00 PM + recur_every: 2, + recur_unit: 'hours', + }, + }) + + const result = getFormattedExecutionTimes(data, 1) + + expect(result[0]).toMatch(/January 16, 2024 4:00 PM/) + expect(result[0]).not.toMatch(/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/) + }) + + test('returns empty array when no execution times', () => { + const data = createMockData({ + mode: 'cron', + cron_expression: 'invalid', + }) + + const result = getFormattedExecutionTimes(data, 5) + + expect(result).toEqual([]) + }) + }) + + describe('getNextExecutionTime', () => { + test('returns first formatted execution time', () => { + const data = createMockData({ + frequency: 'daily', + visual_config: { time: '2:30 PM' }, + }) + + const result = getNextExecutionTime(data) + + expect(result).toMatch(/January \d+, 2024 2:30 PM/) + }) + + test('returns current time when no execution times available for non-once frequencies', () => { + const data = createMockData({ + mode: 'cron', + cron_expression: 'invalid', + }) + + const result = getNextExecutionTime(data) + + expect(result).toMatch(/January 15, 2024 10:00 AM/) + }) + + test('returns default datetime for once frequency when no datetime configured', () => { + const data = createMockData({ + frequency: 'once', + visual_config: {}, + }) + + const result = getNextExecutionTime(data) + + expect(result).toMatch(/January 16, 2024 11:30 AM/) + }) + + test('returns configured datetime for once frequency when available', () => { + const selectedTime = new Date(2024, 0, 20, 15, 30, 0) + const data = createMockData({ + frequency: 'once', + visual_config: { + datetime: selectedTime.toISOString(), + }, + }) + + const result = getNextExecutionTime(data) + + expect(result).toMatch(/January 20, 2024 3:30 PM/) + }) + + test('applies correct weekday formatting based on frequency', () => { + const weeklyData = createMockData({ + frequency: 'weekly', + visual_config: { + time: '2:30 PM', + weekdays: ['sun'], + }, + }) + + const dailyData = createMockData({ + frequency: 'daily', + visual_config: { time: '2:30 PM' }, + }) + + const weeklyResult = getNextExecutionTime(weeklyData) + const dailyResult = getNextExecutionTime(dailyData) + + expect(weeklyResult).toMatch(/^Sun,/) + expect(dailyResult).not.toMatch(/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/) + }) + }) + + describe('edge cases and error handling', () => { + test('handles missing visual_config gracefully', () => { + const data = createMockData({ + frequency: 'daily', + visual_config: undefined, + }) + + const result = getNextExecutionTimes(data, 1) + + expect(result).toHaveLength(1) + }) + + test('uses default values for missing config properties', () => { + const data = createMockData({ + frequency: 'hourly', + visual_config: {}, + }) + + const result = getNextExecutionTimes(data, 1) + + expect(result).toHaveLength(1) + }) + + test('handles malformed time strings gracefully', () => { + const data = createMockData({ + frequency: 'daily', + visual_config: { time: 'invalid time' }, + }) + + expect(() => getNextExecutionTimes(data, 1)).not.toThrow() + }) + + test('returns reasonable defaults for zero count', () => { + const data = createMockData({ + frequency: 'daily', + visual_config: { time: '2:30 PM' }, + }) + + const result = getNextExecutionTimes(data, 0) + + expect(result).toEqual([]) + }) + + test('daily frequency should not have duplicate dates', () => { + const data = createMockData({ + frequency: 'daily', + visual_config: { time: '2:30 PM' }, + }) + + const result = getNextExecutionTimes(data, 5) + + expect(result).toHaveLength(5) + + // Check that each date is unique and consecutive + for (let i = 1; i < result.length; i++) { + const prevDate = result[i - 1].getDate() + const currDate = result[i].getDate() + expect(currDate).not.toBe(prevDate) // No duplicates + expect(currDate - prevDate).toBe(1) // Should be consecutive days + } + }) + }) + + describe('getNextExecutionTimes - monthly frequency', () => { + test('returns monthly execution times for specific day', () => { + const data = createMockData({ + frequency: 'monthly', + visual_config: { + time: '2:30 PM', + monthly_day: 15, + }, + }) + + const result = getNextExecutionTimes(data, 3) + + expect(result).toHaveLength(3) + result.forEach((date) => { + expect(date.getDate()).toBe(15) + expect(date.getHours()).toBe(14) + expect(date.getMinutes()).toBe(30) + }) + + expect(result[0].getMonth()).toBe(0) // January + expect(result[1].getMonth()).toBe(1) // February + expect(result[2].getMonth()).toBe(2) // March + }) + + test('returns monthly execution times for last day', () => { + const data = createMockData({ + frequency: 'monthly', + visual_config: { + time: '11:30 AM', + monthly_day: 'last', + }, + }) + + const result = getNextExecutionTimes(data, 4) + + expect(result).toHaveLength(4) + result.forEach((date) => { + expect(date.getHours()).toBe(11) + expect(date.getMinutes()).toBe(30) + }) + + expect(result[0].getDate()).toBe(31) // January 31 + expect(result[1].getDate()).toBe(29) // February 29 (2024 is leap year) + expect(result[2].getDate()).toBe(31) // March 31 + expect(result[3].getDate()).toBe(30) // April 30 + }) + + test('handles day 31 in months with fewer days', () => { + const data = createMockData({ + frequency: 'monthly', + visual_config: { + time: '3:00 PM', + monthly_day: 31, + }, + }) + + const result = getNextExecutionTimes(data, 4) + + expect(result).toHaveLength(4) + expect(result[0].getDate()).toBe(31) // January 31 + expect(result[1].getDate()).toBe(29) // February 29 (can't have 31) + expect(result[2].getDate()).toBe(31) // March 31 + expect(result[3].getDate()).toBe(30) // April 30 (can't have 31) + }) + + test('handles day 30 in February', () => { + const data = createMockData({ + frequency: 'monthly', + visual_config: { + time: '9:00 AM', + monthly_day: 30, + }, + }) + + const result = getNextExecutionTimes(data, 3) + + expect(result).toHaveLength(3) + expect(result[0].getDate()).toBe(30) // January 30 + expect(result[1].getDate()).toBe(29) // February 29 (max in 2024) + expect(result[2].getDate()).toBe(30) // March 30 + }) + + test('skips to next month if current month execution has passed', () => { + jest.useFakeTimers() + jest.setSystemTime(new Date(2024, 0, 20, 15, 0, 0)) // January 20, 2024 3:00 PM + + const data = createMockData({ + frequency: 'monthly', + visual_config: { + time: '2:30 PM', + monthly_day: 15, // Already passed in January + }, + }) + + const result = getNextExecutionTimes(data, 3) + + expect(result).toHaveLength(3) + expect(result[0].getMonth()).toBe(1) // February (skip January) + expect(result[1].getMonth()).toBe(2) // March + expect(result[2].getMonth()).toBe(3) // April + + jest.useRealTimers() + }) + + test('includes current month if execution time has not passed', () => { + jest.useFakeTimers() + jest.setSystemTime(new Date(2024, 0, 10, 10, 0, 0)) // January 10, 2024 10:00 AM + + const data = createMockData({ + frequency: 'monthly', + visual_config: { + time: '2:30 PM', + monthly_day: 15, // Still upcoming in January + }, + }) + + const result = getNextExecutionTimes(data, 3) + + expect(result).toHaveLength(3) + expect(result[0].getMonth()).toBe(0) // January (current month) + expect(result[1].getMonth()).toBe(1) // February + expect(result[2].getMonth()).toBe(2) // March + + jest.useRealTimers() + }) + + test('handles AM/PM time conversion correctly', () => { + const data = createMockData({ + frequency: 'monthly', + visual_config: { + time: '11:30 PM', + monthly_day: 1, + }, + }) + + const result = getNextExecutionTimes(data, 2) + + expect(result).toHaveLength(2) + result.forEach((date) => { + expect(date.getHours()).toBe(23) // 11 PM in 24-hour format + expect(date.getMinutes()).toBe(30) + expect(date.getDate()).toBe(1) + }) + }) + + test('formats monthly execution times without weekday', () => { + const data = createMockData({ + frequency: 'monthly', + visual_config: { + time: '2:30 PM', + monthly_day: 15, + }, + }) + + const result = getFormattedExecutionTimes(data, 1) + + expect(result).toHaveLength(1) + expect(result[0]).not.toMatch(/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/) + expect(result[0]).toMatch(/January 15, 2024 2:30 PM/) + }) + + test('uses default day 1 when monthly_day is not specified', () => { + const data = createMockData({ + frequency: 'monthly', + visual_config: { + time: '10:00 AM', + }, + }) + + const result = getNextExecutionTimes(data, 2) + + expect(result).toHaveLength(2) + result.forEach((date) => { + expect(date.getDate()).toBe(1) + expect(date.getHours()).toBe(10) + expect(date.getMinutes()).toBe(0) + }) + }) + }) + + describe('getDefaultDateTime', () => { + test('returns consistent default datetime', () => { + const defaultDate = getDefaultDateTime() + + expect(defaultDate.getHours()).toBe(11) + expect(defaultDate.getMinutes()).toBe(30) + expect(defaultDate.getSeconds()).toBe(0) + expect(defaultDate.getMilliseconds()).toBe(0) + expect(defaultDate.getDate()).toBe(new Date().getDate() + 1) + }) + + test('default datetime matches DateTimePicker fallback behavior', () => { + const data = createMockData({ + frequency: 'once', + visual_config: {}, + }) + + const nextExecutionTime = getNextExecutionTime(data) + const defaultDate = getDefaultDateTime() + const expectedFormat = formatExecutionTime(defaultDate, false) + + expect(nextExecutionTime).toBe(expectedFormat) + }) + }) +}) diff --git a/web/app/components/workflow/nodes/trigger-schedule/utils/execution-time-calculator.ts b/web/app/components/workflow/nodes/trigger-schedule/utils/execution-time-calculator.ts new file mode 100644 index 0000000000..cbb118ecc6 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/utils/execution-time-calculator.ts @@ -0,0 +1,200 @@ +import type { ScheduleTriggerNodeType } from '../types' +import { isValidCronExpression, parseCronExpression } from './cron-parser' + +// Helper function to get current time - timezone is handled by Date object natively +const getCurrentTime = (): Date => { + return new Date() +} + +// Helper function to get default datetime for once/hourly modes - consistent with DateTimePicker +export const getDefaultDateTime = (): Date => { + const defaultDate = new Date() + defaultDate.setHours(11, 30, 0, 0) + defaultDate.setDate(defaultDate.getDate() + 1) + return defaultDate +} + +export const getNextExecutionTimes = (data: ScheduleTriggerNodeType, count: number = 5): Date[] => { + if (data.mode === 'cron') { + if (!data.cron_expression || !isValidCronExpression(data.cron_expression)) + return [] + return parseCronExpression(data.cron_expression).slice(0, count) + } + + const times: Date[] = [] + const defaultTime = data.visual_config?.time || '11:30 AM' + + if (data.frequency === 'hourly') { + const recurEvery = data.visual_config?.recur_every || 1 + const recurUnit = data.visual_config?.recur_unit || 'hours' + const startTime = data.visual_config?.datetime ? new Date(data.visual_config.datetime) : getCurrentTime() + + const intervalMs = recurUnit === 'hours' + ? recurEvery * 60 * 60 * 1000 + : recurEvery * 60 * 1000 + + // Calculate the initial offset if start time has passed + const now = getCurrentTime() + let initialOffset = 0 + + if (startTime <= now) { + const timeDiff = now.getTime() - startTime.getTime() + initialOffset = Math.floor(timeDiff / intervalMs) + } + + for (let i = 0; i < count; i++) { + const nextExecution = new Date(startTime.getTime() + (initialOffset + i + 1) * intervalMs) + times.push(nextExecution) + } + } + else if (data.frequency === 'daily') { + const [time, period] = defaultTime.split(' ') + const [hour, minute] = time.split(':') + let displayHour = Number.parseInt(hour) + if (period === 'PM' && displayHour !== 12) displayHour += 12 + if (period === 'AM' && displayHour === 12) displayHour = 0 + + const now = getCurrentTime() + const baseExecution = new Date(now.getFullYear(), now.getMonth(), now.getDate(), displayHour, Number.parseInt(minute), 0, 0) + + // Calculate initial offset: if time has passed today, start from tomorrow + const initialOffset = baseExecution <= now ? 1 : 0 + + for (let i = 0; i < count; i++) { + const nextExecution = new Date(baseExecution) + nextExecution.setDate(baseExecution.getDate() + initialOffset + i) + times.push(nextExecution) + } + } + else if (data.frequency === 'weekly') { + const selectedDay = data.visual_config?.weekdays?.[0] || 'sun' + const dayMap = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 } + const targetDay = dayMap[selectedDay as keyof typeof dayMap] + + const [time, period] = defaultTime.split(' ') + const [hour, minute] = time.split(':') + let displayHour = Number.parseInt(hour) + if (period === 'PM' && displayHour !== 12) displayHour += 12 + if (period === 'AM' && displayHour === 12) displayHour = 0 + + const now = getCurrentTime() + const currentDay = now.getDay() + let daysUntilNext = (targetDay - currentDay + 7) % 7 + + const nextExecutionBase = new Date(now.getFullYear(), now.getMonth(), now.getDate(), displayHour, Number.parseInt(minute), 0, 0) + + if (daysUntilNext === 0 && nextExecutionBase <= now) + daysUntilNext = 7 + + for (let i = 0; i < count; i++) { + const nextExecution = new Date(nextExecutionBase) + nextExecution.setDate(nextExecution.getDate() + daysUntilNext + (i * 7)) + times.push(nextExecution) + } + } + else if (data.frequency === 'monthly') { + const selectedDay = data.visual_config?.monthly_day || 1 + const [time, period] = defaultTime.split(' ') + const [hour, minute] = time.split(':') + let displayHour = Number.parseInt(hour) + if (period === 'PM' && displayHour !== 12) displayHour += 12 + if (period === 'AM' && displayHour === 12) displayHour = 0 + + const now = getCurrentTime() + let monthOffset = 0 + + const currentMonthExecution = (() => { + const currentMonth = new Date(now.getFullYear(), now.getMonth(), 1) + let targetDay: number + + if (selectedDay === 'last') { + const lastDayOfMonth = new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 0).getDate() + targetDay = lastDayOfMonth + } + else { + targetDay = Math.min(selectedDay as number, new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 0).getDate()) + } + + return new Date(currentMonth.getFullYear(), currentMonth.getMonth(), targetDay, displayHour, Number.parseInt(minute), 0, 0) + })() + + if (currentMonthExecution <= now) + monthOffset = 1 + + for (let i = 0; i < count; i++) { + const targetMonth = new Date(now.getFullYear(), now.getMonth() + monthOffset + i, 1) + let targetDay: number + + if (selectedDay === 'last') { + const lastDayOfMonth = new Date(targetMonth.getFullYear(), targetMonth.getMonth() + 1, 0).getDate() + targetDay = lastDayOfMonth + } + else { + targetDay = Math.min(selectedDay as number, new Date(targetMonth.getFullYear(), targetMonth.getMonth() + 1, 0).getDate()) + } + + const nextExecution = new Date(targetMonth.getFullYear(), targetMonth.getMonth(), targetDay, displayHour, Number.parseInt(minute), 0, 0) + times.push(nextExecution) + } + } + else if (data.frequency === 'once') { + // For 'once' frequency, return the selected datetime + const selectedDateTime = data.visual_config?.datetime + if (selectedDateTime) + times.push(new Date(selectedDateTime)) + } + else { + // Fallback for unknown frequencies + for (let i = 0; i < count; i++) { + const now = getCurrentTime() + const nextExecution = new Date(now.getFullYear(), now.getMonth(), now.getDate() + i + 1) + times.push(nextExecution) + } + } + + return times +} + +export const formatExecutionTime = (date: Date, includeWeekday: boolean = true): string => { + const dateOptions: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: 'long', + day: 'numeric', + } + + if (includeWeekday) + dateOptions.weekday = 'short' + + const timeOptions: Intl.DateTimeFormatOptions = { + hour: 'numeric', + minute: '2-digit', + hour12: true, + } + + // Always use local time for display to match calculation logic + return `${date.toLocaleDateString('en-US', dateOptions)} ${date.toLocaleTimeString('en-US', timeOptions)}` +} + +export const getFormattedExecutionTimes = (data: ScheduleTriggerNodeType, count: number = 5): string[] => { + const times = getNextExecutionTimes(data, count) + + return times.map((date) => { + // Only weekly frequency includes weekday in format + const includeWeekday = data.frequency === 'weekly' + return formatExecutionTime(date, includeWeekday) + }) +} + +export const getNextExecutionTime = (data: ScheduleTriggerNodeType): string => { + const times = getFormattedExecutionTimes(data, 1) + if (times.length === 0) { + if (data.frequency === 'once') { + const defaultDate = getDefaultDateTime() + return formatExecutionTime(defaultDate, false) + } + const now = getCurrentTime() + const includeWeekday = data.frequency === 'weekly' + return formatExecutionTime(now, includeWeekday) + } + return times[0] +} diff --git a/web/app/components/workflow/nodes/trigger-webhook/default.ts b/web/app/components/workflow/nodes/trigger-webhook/default.ts new file mode 100644 index 0000000000..104f9fb0b2 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-webhook/default.ts @@ -0,0 +1,31 @@ +import { BlockEnum } from '../../types' +import type { NodeDefault } from '../../types' +import type { WebhookTriggerNodeType } from './types' +import { ALL_CHAT_AVAILABLE_BLOCKS, ALL_COMPLETION_AVAILABLE_BLOCKS } from '@/app/components/workflow/blocks' + +const nodeDefault: NodeDefault = { + defaultValue: { + webhook_url: '', + http_methods: ['POST'], + authorization: { + type: 'none', + }, + }, + getAvailablePrevNodes(isChatMode: boolean) { + return [] + }, + getAvailableNextNodes(isChatMode: boolean) { + const nodes = isChatMode + ? ALL_CHAT_AVAILABLE_BLOCKS + : ALL_COMPLETION_AVAILABLE_BLOCKS.filter(type => type !== BlockEnum.End) + return nodes.filter(type => type !== BlockEnum.Start) + }, + checkValid(payload: WebhookTriggerNodeType, t: any) { + return { + isValid: true, + errorMessage: '', + } + }, +} + +export default nodeDefault diff --git a/web/app/components/workflow/nodes/trigger-webhook/node.tsx b/web/app/components/workflow/nodes/trigger-webhook/node.tsx new file mode 100644 index 0000000000..bb6fd48283 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-webhook/node.tsx @@ -0,0 +1,28 @@ +import type { FC } from 'react' +import React from 'react' +import { useTranslation } from 'react-i18next' +import type { WebhookTriggerNodeType } from './types' +import type { NodeProps } from '@/app/components/workflow/types' + +const i18nPrefix = 'workflow.nodes.triggerWebhook' + +const Node: FC> = ({ + data, +}) => { + const { t } = useTranslation() + + return ( +
+
+ {t(`${i18nPrefix}.nodeTitle`)} +
+ {data.http_methods && data.http_methods.length > 0 && ( +
+ {data.http_methods.join(', ')} +
+ )} +
+ ) +} + +export default React.memo(Node) diff --git a/web/app/components/workflow/nodes/trigger-webhook/panel.tsx b/web/app/components/workflow/nodes/trigger-webhook/panel.tsx new file mode 100644 index 0000000000..ff2ef5cec9 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-webhook/panel.tsx @@ -0,0 +1,29 @@ +import type { FC } from 'react' +import React from 'react' +import { useTranslation } from 'react-i18next' +import type { WebhookTriggerNodeType } from './types' +import Field from '@/app/components/workflow/nodes/_base/components/field' +import type { NodePanelProps } from '@/app/components/workflow/types' + +const i18nPrefix = 'workflow.nodes.triggerWebhook' + +const Panel: FC> = ({ + id, + data, +}) => { + const { t } = useTranslation() + + return ( +
+
+ +
+ {t(`${i18nPrefix}.configPlaceholder`)} +
+
+
+
+ ) +} + +export default React.memo(Panel) diff --git a/web/app/components/workflow/nodes/trigger-webhook/types.ts b/web/app/components/workflow/nodes/trigger-webhook/types.ts new file mode 100644 index 0000000000..8a118e7086 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-webhook/types.ts @@ -0,0 +1,10 @@ +import type { CommonNodeType } from '@/app/components/workflow/types' + +export type WebhookTriggerNodeType = CommonNodeType & { + webhook_url?: string + http_methods?: string[] + authorization?: { + type: 'none' | 'bearer' | 'api_key' + config?: Record + } +} diff --git a/web/app/components/workflow/operator/add-block.tsx b/web/app/components/workflow/operator/add-block.tsx index 5bc541a45a..5d5e26d503 100644 --- a/web/app/components/workflow/operator/add-block.tsx +++ b/web/app/components/workflow/operator/add-block.tsx @@ -13,6 +13,7 @@ import { } from '../utils' import { useAvailableBlocks, + useIsChatMode, useNodesReadOnly, usePanelInteractions, } from '../hooks' @@ -39,6 +40,7 @@ const AddBlock = ({ const { t } = useTranslation() const store = useStoreApi() const workflowStore = useWorkflowStore() + const isChatMode = useIsChatMode() const { nodesReadOnly } = useNodesReadOnly() const { handlePaneContextmenuCancel } = usePanelInteractions() const [open, setOpen] = useState(false) @@ -104,6 +106,7 @@ const AddBlock = ({ trigger={renderTrigger || renderTriggerElement} popupClassName='!min-w-[256px]' availableBlocksTypes={availableNextBlocks} + showStartTab={!isChatMode} /> ) } diff --git a/web/app/components/workflow/types.ts b/web/app/components/workflow/types.ts index 61ebdb64a2..b68c4771a1 100644 --- a/web/app/components/workflow/types.ts +++ b/web/app/components/workflow/types.ts @@ -42,6 +42,9 @@ export enum BlockEnum { Loop = 'loop', LoopStart = 'loop-start', LoopEnd = 'loop-end', + TriggerSchedule = 'trigger-schedule', + TriggerWebhook = 'trigger-webhook', + TriggerPlugin = 'trigger-plugin', } export enum ControlMode { @@ -453,3 +456,13 @@ export enum VersionHistoryContextMenuOptions { export type ChildNodeTypeCount = { [key: string]: number; } + +export const TRIGGER_NODE_TYPES = [ + BlockEnum.TriggerSchedule, + BlockEnum.TriggerWebhook, + BlockEnum.TriggerPlugin, +] as const + +export function isTriggerNode(nodeType: BlockEnum): boolean { + return TRIGGER_NODE_TYPES.includes(nodeType as any) +} diff --git a/web/app/components/workflow/utils/workflow-entry.ts b/web/app/components/workflow/utils/workflow-entry.ts new file mode 100644 index 0000000000..724a68a85b --- /dev/null +++ b/web/app/components/workflow/utils/workflow-entry.ts @@ -0,0 +1,26 @@ +import { BlockEnum, type Node, isTriggerNode } from '../types' + +/** + * Get the workflow entry node + * Priority: trigger nodes > start node + */ +export function getWorkflowEntryNode(nodes: Node[]): Node | undefined { + const triggerNode = nodes.find(node => isTriggerNode(node.data.type)) + if (triggerNode) return triggerNode + + return nodes.find(node => node.data.type === BlockEnum.Start) +} + +/** + * Check if a node type is a workflow entry node + */ +export function isWorkflowEntryNode(nodeType: BlockEnum): boolean { + return nodeType === BlockEnum.Start || isTriggerNode(nodeType) +} + +/** + * Check if workflow is in trigger mode + */ +export function isTriggerWorkflow(nodes: Node[]): boolean { + return nodes.some(node => isTriggerNode(node.data.type)) +} diff --git a/web/i18n/en-US/common.ts b/web/i18n/en-US/common.ts index d9b17dcbad..dd538d9c98 100644 --- a/web/i18n/en-US/common.ts +++ b/web/i18n/en-US/common.ts @@ -37,6 +37,7 @@ const translation = { downloadFailed: 'Download failed. Please try again later.', viewDetails: 'View Details', delete: 'Delete', + now: 'Now', deleteApp: 'Delete App', settings: 'Settings', setup: 'Setup', diff --git a/web/i18n/en-US/workflow.ts b/web/i18n/en-US/workflow.ts index 91a1ff3ba4..d24f2905e3 100644 --- a/web/i18n/en-US/workflow.ts +++ b/web/i18n/en-US/workflow.ts @@ -222,6 +222,7 @@ const translation = { }, tabs: { 'searchBlock': 'Search node', + 'start': 'Start', 'blocks': 'Nodes', 'searchTool': 'Search tool', 'tools': 'Tools', @@ -261,6 +262,9 @@ const translation = { 'loop-start': 'Loop Start', 'loop': 'Loop', 'loop-end': 'Exit Loop', + 'trigger-schedule': 'Schedule Trigger', + 'trigger-webhook': 'Webhook Trigger', + 'trigger-plugin': 'Plugin Trigger', }, blocksAbout: { 'start': 'Define the initial parameters for launching a workflow', @@ -283,6 +287,9 @@ const translation = { 'document-extractor': 'Used to parse uploaded documents into text content that is easily understandable by LLM.', 'list-operator': 'Used to filter or sort array content.', 'agent': 'Invoking large language models to answer questions or process natural language', + 'trigger-schedule': 'Time-based workflow trigger that starts workflows on a schedule', + 'trigger-webhook': 'HTTP callback trigger that starts workflows from external HTTP requests', + 'trigger-plugin': 'Third-party integration trigger that starts workflows from external platform events', }, operator: { zoomIn: 'Zoom In', @@ -917,6 +924,52 @@ const translation = { clickToViewParameterSchema: 'Click to view parameter schema', parameterSchema: 'Parameter Schema', }, + triggerSchedule: { + title: 'Schedule', + nodeTitle: 'Schedule Trigger', + notConfigured: 'Not configured', + useCronExpression: 'Use cron expression', + useVisualPicker: 'Use visual picker', + frequency: { + label: 'FREQUENCY', + hourly: 'Hourly', + daily: 'Daily', + weekly: 'Weekly', + monthly: 'Monthly', + once: 'One time', + }, + selectFrequency: 'Select frequency', + frequencyLabel: 'Frequency', + nextExecution: 'Next execution', + weekdays: 'Week days', + time: 'Time', + cronExpression: 'Cron expression', + nextExecutionTime: 'NEXT EXECUTION TIME', + nextExecutionTimes: 'Next 5 execution times', + startTime: 'Start Time', + executeNow: 'Execution now', + selectDateTime: 'Select Date & Time', + recurEvery: 'Recur every', + hours: 'Hours', + minutes: 'Minutes', + days: 'Days', + lastDay: 'Last day', + lastDayTooltip: 'Not all months have 31 days. Use the \'last day\' option to select each month\'s final day.', + }, + triggerWebhook: { + title: 'Webhook Trigger', + nodeTitle: '🔗 Webhook Trigger', + configPlaceholder: 'Webhook trigger configuration will be implemented here', + }, + triggerPlugin: { + title: 'Plugin Trigger', + nodeTitle: '🔌 Plugin Trigger', + configPlaceholder: 'Plugin trigger configuration will be implemented here', + }, + }, + triggerStatus: { + enabled: 'TRIGGER', + disabled: 'TRIGGER • DISABLED', }, tracing: { stopBy: 'Stop by {{user}}', diff --git a/web/i18n/ja-JP/common.ts b/web/i18n/ja-JP/common.ts index d0a6b64d6e..73de8896df 100644 --- a/web/i18n/ja-JP/common.ts +++ b/web/i18n/ja-JP/common.ts @@ -66,6 +66,7 @@ const translation = { more: 'もっと', selectAll: 'すべて選択', deSelectAll: 'すべて選択解除', + now: '今', }, errorMsg: { fieldRequired: '{{field}}は必要です', diff --git a/web/i18n/ja-JP/workflow.ts b/web/i18n/ja-JP/workflow.ts index a1e4b92482..7bbd8eeea5 100644 --- a/web/i18n/ja-JP/workflow.ts +++ b/web/i18n/ja-JP/workflow.ts @@ -237,6 +237,7 @@ const translation = { 'agent': 'エージェント戦略', 'addAll': 'すべてを追加する', 'allAdded': 'すべて追加されました', + 'start': '始める', }, blocks: { 'start': '開始', @@ -261,6 +262,9 @@ const translation = { 'loop-start': 'ループ開始', 'loop': 'ループ', 'loop-end': 'ループ完了', + 'trigger-plugin': 'プラグイントリガー', + 'trigger-webhook': 'Webhook トリガー', + 'trigger-schedule': 'スケジュールトリガー', }, blocksAbout: { 'start': 'ワークフロー開始時の初期パラメータを定義します。', @@ -283,6 +287,9 @@ const translation = { 'document-extractor': 'アップロード文書を LLM 処理用に最適化されたテキストに変換します。', 'list-operator': '配列のフィルタリングやソート処理を行います。', 'agent': '大規模言語モデルを活用した質問応答や自然言語処理を実行します。', + 'trigger-schedule': 'スケジュールに基づいてワークフローを開始する時間ベースのトリガー', + 'trigger-webhook': 'HTTP コールバックトリガー、外部 HTTP リクエストによってワークフローを開始します', + 'trigger-plugin': 'サードパーティ統合トリガー、外部プラットフォームのイベントによってワークフローを開始します', }, operator: { zoomIn: '拡大', @@ -917,6 +924,48 @@ const translation = { parameterSchema: 'パラメータスキーマ', clickToViewParameterSchema: 'パラメータースキーマを見るにはクリックしてください', }, + triggerSchedule: { + frequency: { + label: '頻度', + monthly: '毎月', + once: '1回のみ', + weekly: '毎週', + daily: '毎日', + hourly: '毎時', + }, + frequencyLabel: '頻度', + days: '日', + title: 'スケジュール', + minutes: '分', + time: '時刻', + useCronExpression: 'Cron 式を使用', + nextExecutionTimes: '次の5回の実行時刻', + nextExecution: '次回実行', + notConfigured: '未設定', + startTime: '開始時刻', + hours: '時間', + executeNow: '今すぐ実行', + weekdays: '曜日', + selectDateTime: '日時を選択', + recurEvery: '間隔', + cronExpression: 'Cron 式', + selectFrequency: '頻度を選択', + lastDay: '月末', + nextExecutionTime: '次回実行時刻', + lastDayTooltip: 'すべての月に31日があるわけではありません。「月末」オプションを使用して各月の最終日を選択してください。', + useVisualPicker: 'ビジュアル設定を使用', + nodeTitle: 'スケジュールトリガー', + }, + triggerWebhook: { + title: 'Webhook トリガー', + nodeTitle: '🔗 Webhook トリガー', + configPlaceholder: 'Webhook トリガーの設定がここに実装されます', + }, + triggerPlugin: { + title: 'プラグイントリガー', + nodeTitle: '🔌 プラグイントリガー', + configPlaceholder: 'プラグイントリガーの設定がここに実装されます', + }, }, tracing: { stopBy: '{{user}}によって停止', @@ -997,6 +1046,10 @@ const translation = { copyLastRunError: '最後の実行の入力をコピーできませんでした', noMatchingInputsFound: '前回の実行から一致する入力が見つかりませんでした。', }, + triggerStatus: { + enabled: 'トリガー', + disabled: 'トリガー • 無効', + }, } export default translation diff --git a/web/i18n/zh-Hans/common.ts b/web/i18n/zh-Hans/common.ts index e51b84c37e..f1378b1816 100644 --- a/web/i18n/zh-Hans/common.ts +++ b/web/i18n/zh-Hans/common.ts @@ -66,6 +66,7 @@ const translation = { more: '更多', selectAll: '全选', deSelectAll: '取消全选', + now: '现在', }, errorMsg: { fieldRequired: '{{field}} 为必填项', diff --git a/web/i18n/zh-Hans/workflow.ts b/web/i18n/zh-Hans/workflow.ts index 5b56f112c5..85dc8065d9 100644 --- a/web/i18n/zh-Hans/workflow.ts +++ b/web/i18n/zh-Hans/workflow.ts @@ -237,6 +237,7 @@ const translation = { 'agent': 'Agent 策略', 'allAdded': '已添加全部', 'addAll': '添加全部', + 'start': '开始', }, blocks: { 'start': '开始', @@ -261,6 +262,9 @@ const translation = { 'loop-start': '循环开始', 'loop': '循环', 'loop-end': '退出循环', + 'trigger-webhook': 'Webhook 触发器', + 'trigger-schedule': '定时触发器', + 'trigger-plugin': '插件触发器', }, blocksAbout: { 'start': '定义一个 workflow 流程启动的初始参数', @@ -283,6 +287,9 @@ const translation = { 'document-extractor': '用于将用户上传的文档解析为 LLM 便于理解的文本内容。', 'list-operator': '用于过滤或排序数组内容。', 'agent': '调用大型语言模型回答问题或处理自然语言', + 'trigger-webhook': '从外部 HTTP 请求启动工作流的 HTTP 回调触发器', + 'trigger-schedule': '基于时间的工作流触发器,按计划启动工作流', + 'trigger-plugin': '从外部平台事件启动工作流的第三方集成触发器', }, operator: { zoomIn: '放大', @@ -917,6 +924,48 @@ const translation = { clickToViewParameterSchema: '点击查看参数 schema', parameterSchema: '参数 Schema', }, + triggerSchedule: { + frequency: { + label: '频率', + monthly: '每月', + once: '仅一次', + daily: '每日', + hourly: '每小时', + weekly: '每周', + }, + title: '定时触发', + nodeTitle: '定时触发器', + useCronExpression: '使用 Cron 表达式', + selectFrequency: '选择频率', + nextExecutionTimes: '接下来 5 次执行时间', + hours: '小时', + recurEvery: '每隔', + minutes: '分钟', + cronExpression: 'Cron 表达式', + weekdays: '星期', + executeNow: '立即执行', + frequencyLabel: '频率', + nextExecution: '下次执行', + time: '时间', + lastDay: '最后一天', + startTime: '开始时间', + selectDateTime: '选择日期和时间', + lastDayTooltip: '并非所有月份都有 31 天。使用"最后一天"选项来选择每个月的最后一天。', + nextExecutionTime: '下次执行时间', + useVisualPicker: '使用可视化配置', + days: '天', + notConfigured: '未配置', + }, + triggerWebhook: { + configPlaceholder: 'Webhook 触发器配置将在此处实现', + title: 'Webhook 触发器', + nodeTitle: '🔗 Webhook 触发器', + }, + triggerPlugin: { + title: '插件触发器', + nodeTitle: '🔌 插件触发器', + configPlaceholder: '插件触发器配置将在此处实现', + }, }, tracing: { stopBy: '由{{user}}终止', @@ -998,6 +1047,10 @@ const translation = { noDependents: '无被依赖', }, }, + triggerStatus: { + enabled: '触发器', + disabled: '触发器 • 已禁用', + }, } export default translation diff --git a/web/themes/dark.css b/web/themes/dark.css index 9b9d467b08..16a6441140 100644 --- a/web/themes/dark.css +++ b/web/themes/dark.css @@ -434,7 +434,7 @@ html[data-theme="dark"] { --color-workflow-block-bg: #27272b; --color-workflow-block-bg-transparent: rgb(39 39 43 / 0.96); --color-workflow-block-border-highlight: rgb(200 206 218 / 0.2); - --color-workflow-block-wrapper-bg-1: #27272b; + --color-workflow-block-wrapper-bg-1: #323236; --color-workflow-block-wrapper-bg-2: rgb(39 39 43 / 0.2); --color-workflow-canvas-workflow-dot-color: rgb(133 133 173 / 0.11); From e9c7dc74645f94bd2583b6958a968a4ace1be6fd Mon Sep 17 00:00:00 2001 From: lyzno1 <92089059+lyzno1@users.noreply.github.com> Date: Mon, 18 Aug 2025 10:44:17 +0800 Subject: [PATCH 004/180] feat: update workflow run button to Test Run with keyboard shortcut (#24071) --- web/app/components/workflow/header/run-and-history.tsx | 2 ++ web/app/components/workflow/shortcuts-name.tsx | 7 ++++++- web/i18n/de-DE/workflow.ts | 2 +- web/i18n/en-US/workflow.ts | 2 +- web/i18n/es-ES/workflow.ts | 2 +- web/i18n/fa-IR/workflow.ts | 2 +- web/i18n/fr-FR/workflow.ts | 2 +- web/i18n/hi-IN/workflow.ts | 2 +- web/i18n/it-IT/workflow.ts | 2 +- web/i18n/ja-JP/workflow.ts | 2 +- web/i18n/ko-KR/workflow.ts | 2 +- web/i18n/pl-PL/workflow.ts | 2 +- web/i18n/pt-BR/workflow.ts | 2 +- web/i18n/ro-RO/workflow.ts | 2 +- web/i18n/ru-RU/workflow.ts | 2 +- web/i18n/sl-SI/workflow.ts | 2 +- web/i18n/th-TH/workflow.ts | 2 +- web/i18n/tr-TR/workflow.ts | 2 +- web/i18n/uk-UA/workflow.ts | 2 +- web/i18n/vi-VN/workflow.ts | 2 +- web/i18n/zh-Hans/workflow.ts | 2 +- web/i18n/zh-Hant/workflow.ts | 2 +- 22 files changed, 28 insertions(+), 21 deletions(-) diff --git a/web/app/components/workflow/header/run-and-history.tsx b/web/app/components/workflow/header/run-and-history.tsx index bf10e2a3ca..0667a8af89 100644 --- a/web/app/components/workflow/header/run-and-history.tsx +++ b/web/app/components/workflow/header/run-and-history.tsx @@ -22,6 +22,7 @@ import { import { useEventEmitterContextContext } from '@/context/event-emitter' import { EVENT_WORKFLOW_STOP } from '@/app/components/workflow/variable-inspect/types' import useTheme from '@/hooks/use-theme' +import ShortcutsName from '../shortcuts-name' const RunMode = memo(() => { const { t } = useTranslation() @@ -64,6 +65,7 @@ const RunMode = memo(() => { <> {t('workflow.common.run')} + ) } diff --git a/web/app/components/workflow/shortcuts-name.tsx b/web/app/components/workflow/shortcuts-name.tsx index e7122c5ad5..fcf44a10eb 100644 --- a/web/app/components/workflow/shortcuts-name.tsx +++ b/web/app/components/workflow/shortcuts-name.tsx @@ -5,10 +5,12 @@ import cn from '@/utils/classnames' type ShortcutsNameProps = { keys: string[] className?: string + textColor?: 'default' | 'secondary' } const ShortcutsName = ({ keys, className, + textColor = 'default', }: ShortcutsNameProps) => { return (
(
{getKeyboardKeyNameBySystem(key)}
diff --git a/web/i18n/de-DE/workflow.ts b/web/i18n/de-DE/workflow.ts index d40c640df8..e693d8a64a 100644 --- a/web/i18n/de-DE/workflow.ts +++ b/web/i18n/de-DE/workflow.ts @@ -8,7 +8,7 @@ const translation = { published: 'Veröffentlicht', publish: 'Veröffentlichen', update: 'Aktualisieren', - run: 'Ausführen', + run: 'Test ausführen', running: 'Wird ausgeführt', inRunMode: 'Im Ausführungsmodus', inPreview: 'In der Vorschau', diff --git a/web/i18n/en-US/workflow.ts b/web/i18n/en-US/workflow.ts index d24f2905e3..d7133bec41 100644 --- a/web/i18n/en-US/workflow.ts +++ b/web/i18n/en-US/workflow.ts @@ -9,7 +9,7 @@ const translation = { publish: 'Publish', update: 'Update', publishUpdate: 'Publish Update', - run: 'Run', + run: 'Test Run', running: 'Running', inRunMode: 'In Run Mode', inPreview: 'In Preview', diff --git a/web/i18n/es-ES/workflow.ts b/web/i18n/es-ES/workflow.ts index aff794f1f6..0cad3627d9 100644 --- a/web/i18n/es-ES/workflow.ts +++ b/web/i18n/es-ES/workflow.ts @@ -8,7 +8,7 @@ const translation = { published: 'Publicado', publish: 'Publicar', update: 'Actualizar', - run: 'Ejecutar', + run: 'Ejecutar prueba', running: 'Ejecutando', inRunMode: 'En modo de ejecución', inPreview: 'En vista previa', diff --git a/web/i18n/fa-IR/workflow.ts b/web/i18n/fa-IR/workflow.ts index ee3ce148cf..8e1ada19aa 100644 --- a/web/i18n/fa-IR/workflow.ts +++ b/web/i18n/fa-IR/workflow.ts @@ -8,7 +8,7 @@ const translation = { published: 'منتشر شده', publish: 'انتشار', update: 'به‌روزرسانی', - run: 'اجرا', + run: 'اجرای تست', running: 'در حال اجرا', inRunMode: 'در حالت اجرا', inPreview: 'در پیش‌نمایش', diff --git a/web/i18n/fr-FR/workflow.ts b/web/i18n/fr-FR/workflow.ts index 8022768d44..83a3ddac56 100644 --- a/web/i18n/fr-FR/workflow.ts +++ b/web/i18n/fr-FR/workflow.ts @@ -8,7 +8,7 @@ const translation = { published: 'Publié', publish: 'Publier', update: 'Mettre à jour', - run: 'Exécuter', + run: 'Exécuter test', running: 'En cours d\'exécution', inRunMode: 'En mode exécution', inPreview: 'En aperçu', diff --git a/web/i18n/hi-IN/workflow.ts b/web/i18n/hi-IN/workflow.ts index 2d04883762..d3b1cc432d 100644 --- a/web/i18n/hi-IN/workflow.ts +++ b/web/i18n/hi-IN/workflow.ts @@ -8,7 +8,7 @@ const translation = { published: 'प्रकाशित', publish: 'प्रकाशित करें', update: 'अपडेट करें', - run: 'चलाएं', + run: 'परीक्षण चलाएं', running: 'चल रहा है', inRunMode: 'रन मोड में', inPreview: 'पूर्वावलोकन में', diff --git a/web/i18n/it-IT/workflow.ts b/web/i18n/it-IT/workflow.ts index 0b687906fd..6c5aed7693 100644 --- a/web/i18n/it-IT/workflow.ts +++ b/web/i18n/it-IT/workflow.ts @@ -8,7 +8,7 @@ const translation = { published: 'Pubblicato', publish: 'Pubblica', update: 'Aggiorna', - run: 'Esegui', + run: 'Esegui test', running: 'In esecuzione', inRunMode: 'In modalità di esecuzione', inPreview: 'In anteprima', diff --git a/web/i18n/ja-JP/workflow.ts b/web/i18n/ja-JP/workflow.ts index 7bbd8eeea5..16d3b2f789 100644 --- a/web/i18n/ja-JP/workflow.ts +++ b/web/i18n/ja-JP/workflow.ts @@ -9,7 +9,7 @@ const translation = { publish: '公開する', update: '更新', publishUpdate: '更新を公開', - run: '実行', + run: 'テスト実行', running: '実行中', inRunMode: '実行モード中', inPreview: 'プレビュー中', diff --git a/web/i18n/ko-KR/workflow.ts b/web/i18n/ko-KR/workflow.ts index c2ec86f059..da735703c0 100644 --- a/web/i18n/ko-KR/workflow.ts +++ b/web/i18n/ko-KR/workflow.ts @@ -8,7 +8,7 @@ const translation = { published: '게시됨', publish: '게시하기', update: '업데이트', - run: '실행', + run: '테스트 실행', running: '실행 중', inRunMode: '실행 모드', inPreview: '미리보기 중', diff --git a/web/i18n/pl-PL/workflow.ts b/web/i18n/pl-PL/workflow.ts index 8f1d76dcf2..7450388803 100644 --- a/web/i18n/pl-PL/workflow.ts +++ b/web/i18n/pl-PL/workflow.ts @@ -8,7 +8,7 @@ const translation = { published: 'Opublikowane', publish: 'Opublikuj', update: 'Aktualizuj', - run: 'Uruchom', + run: 'Uruchom test', running: 'Uruchamianie', inRunMode: 'W trybie uruchamiania', inPreview: 'W podglądzie', diff --git a/web/i18n/pt-BR/workflow.ts b/web/i18n/pt-BR/workflow.ts index a490bc0687..ab872893d8 100644 --- a/web/i18n/pt-BR/workflow.ts +++ b/web/i18n/pt-BR/workflow.ts @@ -8,7 +8,7 @@ const translation = { published: 'Publicado', publish: 'Publicar', update: 'Atualizar', - run: 'Executar', + run: 'Executar teste', running: 'Executando', inRunMode: 'No modo de execução', inPreview: 'Em visualização', diff --git a/web/i18n/ro-RO/workflow.ts b/web/i18n/ro-RO/workflow.ts index 1e55d53235..0720cf8873 100644 --- a/web/i18n/ro-RO/workflow.ts +++ b/web/i18n/ro-RO/workflow.ts @@ -8,7 +8,7 @@ const translation = { published: 'Publicat', publish: 'Publică', update: 'Actualizează', - run: 'Rulează', + run: 'Rulează test', running: 'Rulând', inRunMode: 'În modul de rulare', inPreview: 'În previzualizare', diff --git a/web/i18n/ru-RU/workflow.ts b/web/i18n/ru-RU/workflow.ts index 48aa9b6e58..37cfb731ef 100644 --- a/web/i18n/ru-RU/workflow.ts +++ b/web/i18n/ru-RU/workflow.ts @@ -8,7 +8,7 @@ const translation = { published: 'Опубликовано', publish: 'Опубликовать', update: 'Обновить', - run: 'Запустить', + run: 'Тестовый запуск', running: 'Выполняется', inRunMode: 'В режиме выполнения', inPreview: 'В режиме предпросмотра', diff --git a/web/i18n/sl-SI/workflow.ts b/web/i18n/sl-SI/workflow.ts index c7602b1349..c4de67225b 100644 --- a/web/i18n/sl-SI/workflow.ts +++ b/web/i18n/sl-SI/workflow.ts @@ -18,7 +18,7 @@ const translation = { }, versionHistory: 'Zgodovina različic', published: 'Objavljeno', - run: 'Teči', + run: 'Testni tek', featuresDocLink: 'Nauči se več', notRunning: 'Še ne teče', exportImage: 'Izvozi sliko', diff --git a/web/i18n/th-TH/workflow.ts b/web/i18n/th-TH/workflow.ts index 8d4ca5e7ca..fb620883c7 100644 --- a/web/i18n/th-TH/workflow.ts +++ b/web/i18n/th-TH/workflow.ts @@ -8,7 +8,7 @@ const translation = { published: 'เผย แพร่', publish: 'ตีพิมพ์', update: 'อัพเดต', - run: 'วิ่ง', + run: 'ทดสอบการทำงาน', running: 'กำลัง เรียก ใช้', inRunMode: 'ในโหมดเรียกใช้', inPreview: 'ในการแสดงตัวอย่าง', diff --git a/web/i18n/tr-TR/workflow.ts b/web/i18n/tr-TR/workflow.ts index ca4ab32ebf..c8f16287f3 100644 --- a/web/i18n/tr-TR/workflow.ts +++ b/web/i18n/tr-TR/workflow.ts @@ -8,7 +8,7 @@ const translation = { published: 'Yayınlandı', publish: 'Yayınla', update: 'Güncelle', - run: 'Çalıştır', + run: 'Test çalıştır', running: 'Çalışıyor', inRunMode: 'Çalıştırma Modunda', inPreview: 'Ön İzlemede', diff --git a/web/i18n/uk-UA/workflow.ts b/web/i18n/uk-UA/workflow.ts index 47c1a12128..2b81ce2a86 100644 --- a/web/i18n/uk-UA/workflow.ts +++ b/web/i18n/uk-UA/workflow.ts @@ -8,7 +8,7 @@ const translation = { published: 'Опубліковано', publish: 'Опублікувати', update: 'Оновити', - run: 'Запустити', + run: 'Тестовий запуск', running: 'Запущено', inRunMode: 'У режимі запуску', inPreview: 'У режимі попереднього перегляду', diff --git a/web/i18n/vi-VN/workflow.ts b/web/i18n/vi-VN/workflow.ts index ea4488d020..f70dbd5493 100644 --- a/web/i18n/vi-VN/workflow.ts +++ b/web/i18n/vi-VN/workflow.ts @@ -8,7 +8,7 @@ const translation = { published: 'Đã xuất bản', publish: 'Xuất bản', update: 'Cập nhật', - run: 'Chạy', + run: 'Chạy thử nghiệm', running: 'Đang chạy', inRunMode: 'Chế độ chạy', inPreview: 'Trong chế độ xem trước', diff --git a/web/i18n/zh-Hans/workflow.ts b/web/i18n/zh-Hans/workflow.ts index 85dc8065d9..8c293dd6aa 100644 --- a/web/i18n/zh-Hans/workflow.ts +++ b/web/i18n/zh-Hans/workflow.ts @@ -9,7 +9,7 @@ const translation = { publish: '发布', update: '更新', publishUpdate: '发布更新', - run: '运行', + run: '测试运行', running: '运行中', inRunMode: '在运行模式中', inPreview: '预览中', diff --git a/web/i18n/zh-Hant/workflow.ts b/web/i18n/zh-Hant/workflow.ts index 659bffa390..505dad8bd1 100644 --- a/web/i18n/zh-Hant/workflow.ts +++ b/web/i18n/zh-Hant/workflow.ts @@ -8,7 +8,7 @@ const translation = { published: '已發佈', publish: '發佈', update: '更新', - run: '運行', + run: '測試運行', running: '運行中', inRunMode: '在運行模式中', inPreview: '預覽中', From 5c4bf7aabdc389cfbbf1873d01e0e78fa2c70b20 Mon Sep 17 00:00:00 2001 From: lyzno1 <92089059+lyzno1@users.noreply.github.com> Date: Mon, 18 Aug 2025 17:46:36 +0800 Subject: [PATCH 005/180] feat: Test Run dropdown with dynamic trigger selection (#24113) --- .../icons/assets/vender/workflow/schedule.svg | 5 + .../assets/vender/workflow/webhook-line.svg | 3 + .../icons/src/vender/workflow/Schedule.json | 46 +++++ .../icons/src/vender/workflow/Schedule.tsx | 20 ++ .../src/vender/workflow/WebhookLine.json | 26 +++ .../icons/src/vender/workflow/WebhookLine.tsx | 20 ++ .../base/icons/src/vender/workflow/index.ts | 2 + web/app/components/workflow/block-icon.tsx | 6 + .../workflow/header/run-and-history.tsx | 57 +++--- .../workflow/header/test-run-dropdown.tsx | 186 ++++++++++++++++++ .../_base/components/trigger-container.tsx | 8 +- web/i18n/en-US/workflow.ts | 1 + web/i18n/ja-JP/workflow.ts | 1 + web/i18n/zh-Hans/workflow.ts | 1 + 14 files changed, 354 insertions(+), 28 deletions(-) create mode 100644 web/app/components/base/icons/assets/vender/workflow/schedule.svg create mode 100644 web/app/components/base/icons/assets/vender/workflow/webhook-line.svg create mode 100644 web/app/components/base/icons/src/vender/workflow/Schedule.json create mode 100644 web/app/components/base/icons/src/vender/workflow/Schedule.tsx create mode 100644 web/app/components/base/icons/src/vender/workflow/WebhookLine.json create mode 100644 web/app/components/base/icons/src/vender/workflow/WebhookLine.tsx create mode 100644 web/app/components/workflow/header/test-run-dropdown.tsx diff --git a/web/app/components/base/icons/assets/vender/workflow/schedule.svg b/web/app/components/base/icons/assets/vender/workflow/schedule.svg new file mode 100644 index 0000000000..69977c4c7f --- /dev/null +++ b/web/app/components/base/icons/assets/vender/workflow/schedule.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/web/app/components/base/icons/assets/vender/workflow/webhook-line.svg b/web/app/components/base/icons/assets/vender/workflow/webhook-line.svg new file mode 100644 index 0000000000..16fd30a961 --- /dev/null +++ b/web/app/components/base/icons/assets/vender/workflow/webhook-line.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/app/components/base/icons/src/vender/workflow/Schedule.json b/web/app/components/base/icons/src/vender/workflow/Schedule.json new file mode 100644 index 0000000000..1c2d181dc4 --- /dev/null +++ b/web/app/components/base/icons/src/vender/workflow/Schedule.json @@ -0,0 +1,46 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "width": "16", + "height": "16", + "viewBox": "0 0 16 16", + "fill": "none", + "xmlns": "http://www.w3.org/2000/svg" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M11.3333 9.33337C11.7015 9.33337 11.9999 9.63193 12 10V11.0573L12.8047 11.862L12.8503 11.9128C13.0638 12.1746 13.0487 12.5607 12.8047 12.8047C12.5606 13.0488 12.1746 13.0639 11.9128 12.8503L11.862 12.8047L10.862 11.8047C10.7371 11.6798 10.6667 11.5101 10.6667 11.3334V10C10.6668 9.63193 10.9652 9.33337 11.3333 9.33337Z", + "fill": "currentColor" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "fill-rule": "evenodd", + "clip-rule": "evenodd", + "d": "M11.3333 7.33337C13.5425 7.33337 15.3333 9.12424 15.3333 11.3334C15.3333 13.5425 13.5425 15.3334 11.3333 15.3334C9.12419 15.3334 7.33333 13.5425 7.33333 11.3334C7.33333 9.12424 9.12419 7.33337 11.3333 7.33337ZM11.3333 8.66671C9.86057 8.66671 8.66667 9.86061 8.66667 11.3334C8.66667 12.8061 9.86057 14 11.3333 14C12.8061 14 14 12.8061 14 11.3334C14 9.86061 12.8061 8.66671 11.3333 8.66671Z", + "fill": "currentColor" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M10.6667 1.33337C11.0349 1.33337 11.3333 1.63185 11.3333 2.00004V2.66671H12.6667C13.4031 2.66671 14 3.26367 14 4.00004V5.66671C14 6.0349 13.7015 6.33337 13.3333 6.33337C12.9651 6.33337 12.6667 6.0349 12.6667 5.66671V4.00004H3.33333V12.6667H5.66667C6.03486 12.6667 6.33333 12.9652 6.33333 13.3334C6.33333 13.7016 6.03486 14 5.66667 14H3.33333C2.59697 14 2 13.4031 2 12.6667V4.00004C2 3.26366 2.59696 2.66671 3.33333 2.66671H4.66667V2.00004C4.66667 1.63185 4.96514 1.33337 5.33333 1.33337C5.70152 1.33337 6 1.63185 6 2.00004V2.66671H10V2.00004C10 1.63185 10.2985 1.33337 10.6667 1.33337Z", + "fill": "currentColor" + }, + "children": [] + } + ] + }, + "name": "Schedule" +} diff --git a/web/app/components/base/icons/src/vender/workflow/Schedule.tsx b/web/app/components/base/icons/src/vender/workflow/Schedule.tsx new file mode 100644 index 0000000000..86b8506d6e --- /dev/null +++ b/web/app/components/base/icons/src/vender/workflow/Schedule.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './Schedule.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'Schedule' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/workflow/WebhookLine.json b/web/app/components/base/icons/src/vender/workflow/WebhookLine.json new file mode 100644 index 0000000000..8319fd25f3 --- /dev/null +++ b/web/app/components/base/icons/src/vender/workflow/WebhookLine.json @@ -0,0 +1,26 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "width": "16", + "height": "16", + "viewBox": "0 0 16 16", + "fill": "none", + "xmlns": "http://www.w3.org/2000/svg" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M5.91246 9.42618C5.77036 9.66084 5.70006 9.85191 5.81358 10.1502C6.12696 10.9742 5.68488 11.776 4.85394 11.9937C4.07033 12.199 3.30686 11.684 3.15138 10.8451C3.01362 10.1025 3.58988 9.37451 4.40859 9.25851C4.45305 9.25211 4.49808 9.24938 4.55563 9.24591C4.58692 9.24404 4.62192 9.24191 4.66252 9.23884L5.90792 7.15051C5.12463 6.37166 4.65841 5.46114 4.7616 4.33295C4.83455 3.53543 5.14813 2.84626 5.72135 2.28138C6.81916 1.19968 8.49403 1.02449 9.78663 1.85479C11.0282 2.65232 11.5967 4.20582 11.112 5.53545L9.97403 5.22671C10.1263 4.48748 10.0137 3.82362 9.5151 3.25494C9.1857 2.87947 8.76303 2.68267 8.28236 2.61015C7.31883 2.46458 6.37278 3.08364 6.09207 4.02937C5.77342 5.10275 6.25566 5.97954 7.5735 6.64023C7.0207 7.56944 6.47235 8.50124 5.91246 9.42618ZM9.18916 5.51562C9.5877 6.2187 9.99236 6.93244 10.3934 7.63958C12.4206 7.01244 13.9491 8.13458 14.4974 9.33604C15.1597 10.7873 14.707 12.5062 13.4062 13.4016C12.0711 14.3207 10.3827 14.1636 9.19976 12.983L10.1279 12.2063C11.2962 12.963 12.3181 12.9274 13.0767 12.0314C13.7236 11.2669 13.7096 10.1271 13.0439 9.37871C12.2757 8.51511 11.2467 8.48878 10.0029 9.31784C9.48696 8.40251 8.96196 7.49424 8.46236 6.57234C8.2939 6.2616 8.10783 6.08135 7.72816 6.01558C7.09403 5.90564 6.68463 5.36109 6.66007 4.75099C6.63593 4.14763 6.99136 3.60224 7.54696 3.38974C8.0973 3.17924 8.74316 3.34916 9.11336 3.81707C9.4159 4.19938 9.51203 4.62966 9.35283 5.10116C9.32283 5.19018 9.28689 5.27727 9.2475 5.37261C9.22869 5.418 9.20916 5.46538 9.18916 5.51562ZM7.7013 11.2634H10.1417C10.1757 11.3087 10.2075 11.3536 10.2386 11.3973C10.3034 11.4887 10.3649 11.5755 10.4367 11.6526C10.9536 12.2052 11.8263 12.2326 12.3788 11.7197C12.9514 11.1881 12.9773 10.2951 12.4362 9.74011C11.9068 9.19704 11.0019 9.14518 10.5103 9.72018C10.2117 10.0696 9.9057 10.1107 9.50936 10.1045C8.49423 10.0888 7.47843 10.0994 6.46346 10.0994C6.52934 11.5273 5.98953 12.417 4.9189 12.6283C3.87051 12.8352 2.90496 12.3003 2.56502 11.3243C2.17891 10.2153 2.65641 9.32838 4.0361 8.62444C3.93228 8.24838 3.8274 7.86778 3.72357 7.49071C2.21981 7.81844 1.09162 9.27738 1.20809 10.9187C1.31097 12.3676 2.47975 13.6544 3.90909 13.8849C4.68542 14.0102 5.41485 13.88 6.09157 13.4962C6.96216 13.0022 7.46736 12.2254 7.7013 11.2634Z", + "fill": "currentColor" + }, + "children": [] + } + ] + }, + "name": "WebhookLine" +} diff --git a/web/app/components/base/icons/src/vender/workflow/WebhookLine.tsx b/web/app/components/base/icons/src/vender/workflow/WebhookLine.tsx new file mode 100644 index 0000000000..da1143c16e --- /dev/null +++ b/web/app/components/base/icons/src/vender/workflow/WebhookLine.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './WebhookLine.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'WebhookLine' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/workflow/index.ts b/web/app/components/base/icons/src/vender/workflow/index.ts index 61fbd4b21c..3e7a46dd99 100644 --- a/web/app/components/base/icons/src/vender/workflow/index.ts +++ b/web/app/components/base/icons/src/vender/workflow/index.ts @@ -17,6 +17,8 @@ export { default as LoopEnd } from './LoopEnd' export { default as Loop } from './Loop' export { default as ParameterExtractor } from './ParameterExtractor' export { default as QuestionClassifier } from './QuestionClassifier' +export { default as Schedule } from './Schedule' export { default as TemplatingTransform } from './TemplatingTransform' export { default as VariableX } from './VariableX' +export { default as WebhookLine } from './WebhookLine' export { default as WindowCursor } from './WindowCursor' diff --git a/web/app/components/workflow/block-icon.tsx b/web/app/components/workflow/block-icon.tsx index 1e76efc2aa..d8961c6826 100644 --- a/web/app/components/workflow/block-icon.tsx +++ b/web/app/components/workflow/block-icon.tsx @@ -19,8 +19,10 @@ import { LoopEnd, ParameterExtractor, QuestionClassifier, + Schedule, TemplatingTransform, VariableX, + WebhookLine, } from '@/app/components/base/icons/src/vender/workflow' import AppIcon from '@/app/components/base/app-icon' @@ -60,6 +62,8 @@ const getIcon = (type: BlockEnum, className: string) => { [BlockEnum.DocExtractor]: , [BlockEnum.ListFilter]: , [BlockEnum.Agent]: , + [BlockEnum.TriggerSchedule]: , + [BlockEnum.TriggerWebhook]: , }[type] } const ICON_CONTAINER_BG_COLOR_MAP: Record = { @@ -83,6 +87,8 @@ const ICON_CONTAINER_BG_COLOR_MAP: Record = { [BlockEnum.DocExtractor]: 'bg-util-colors-green-green-500', [BlockEnum.ListFilter]: 'bg-util-colors-cyan-cyan-500', [BlockEnum.Agent]: 'bg-util-colors-indigo-indigo-500', + [BlockEnum.TriggerSchedule]: 'bg-util-colors-violet-violet-500', + [BlockEnum.TriggerWebhook]: 'bg-util-colors-blue-blue-500', } const BlockIcon: FC = ({ type, diff --git a/web/app/components/workflow/header/run-and-history.tsx b/web/app/components/workflow/header/run-and-history.tsx index 0667a8af89..bb50734024 100644 --- a/web/app/components/workflow/header/run-and-history.tsx +++ b/web/app/components/workflow/header/run-and-history.tsx @@ -15,6 +15,8 @@ import { import { WorkflowRunningStatus } from '../types' import ViewHistory from './view-history' import Checklist from './checklist' +import TestRunDropdown, { createMockOptions } from './test-run-dropdown' +import type { TriggerOption } from './test-run-dropdown' import cn from '@/utils/classnames' import { StopCircle, @@ -35,6 +37,11 @@ const RunMode = memo(() => { handleStopRun(workflowRunningData?.task_id || '') } + const handleTriggerSelect = (option: TriggerOption) => { + console.log('Selected trigger:', option) + handleWorkflowStartRunInWorkflow() + } + const { eventEmitter } = useEventEmitterContextContext() eventEmitter?.useSubscription((v: any) => { if (v.type === EVENT_WORKFLOW_STOP) @@ -43,33 +50,35 @@ const RunMode = memo(() => { return ( <> -
{ - handleWorkflowStartRunInWorkflow() - }} - > - { - isRunning - ? ( - <> - - {t('workflow.common.running')} - - ) - : ( - <> + { + isRunning + ? ( +
+ + {t('workflow.common.running')} +
+ ) + : ( + +
{t('workflow.common.run')} - - ) - } -
+
+ + ) + } { isRunning && (
void + children: React.ReactNode +} + +const createMockOptions = (): TestRunOptions => { + const userInput: TriggerOption = { + id: 'user-input-1', + type: 'user_input', + name: 'User Input', + icon: ( +
+ +
+ ), + nodeId: 'start-node-1', + enabled: true, + } + + const runAll: TriggerOption = { + id: 'run-all', + type: 'all', + name: 'Run all triggers', + icon: ( +
+ + + +
+ ), + enabled: true, + } + + const triggers: TriggerOption[] = [ + { + id: 'slack-trigger-1', + type: 'plugin', + name: 'Slack Trigger', + icon: ( +
+ + + + + + +
+ ), + nodeId: 'slack-trigger-1', + enabled: true, + }, + { + id: 'zapier-trigger-1', + type: 'plugin', + name: 'Zapier Trigger', + icon: ( +
+ + + +
+ ), + nodeId: 'zapier-trigger-1', + enabled: true, + }, + { + id: 'gmail-trigger-1', + type: 'plugin', + name: 'Gmail Sender', + icon: ( +
+ +
+ ), + nodeId: 'gmail-trigger-1', + enabled: true, + }, + ] + + return { + userInput, + triggers, + runAll: triggers.length > 1 ? runAll : undefined, + } +} + +const TestRunDropdown: FC = ({ + options, + onSelect, + children, +}) => { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + + const handleSelect = (option: TriggerOption) => { + onSelect(option) + setOpen(false) + } + + const renderOption = (option: TriggerOption, numberDisplay: string) => ( +
handleSelect(option)} + > +
+ {option.icon} + {option.name} +
+
+ {numberDisplay} +
+
+ ) + + const hasUserInput = !!options.userInput + const hasTriggers = options.triggers.length > 0 + const hasRunAll = !!options.runAll + + let currentIndex = 0 + + return ( + + setOpen(!open)}> +
+ {children} +
+
+ +
+
+ {t('workflow.common.chooseStartNodeToRun')} +
+
+ {hasUserInput && renderOption(options.userInput!, '~')} + + {(hasTriggers || hasRunAll) && hasUserInput && ( +
+ )} + + {hasRunAll && renderOption(options.runAll!, String(currentIndex++))} + + {hasTriggers && options.triggers.map(trigger => + renderOption(trigger, String(currentIndex++)), + )} +
+
+ + + ) +} + +export { createMockOptions } +export default TestRunDropdown diff --git a/web/app/components/workflow/nodes/_base/components/trigger-container.tsx b/web/app/components/workflow/nodes/_base/components/trigger-container.tsx index 97853126c0..a4c3224eab 100644 --- a/web/app/components/workflow/nodes/_base/components/trigger-container.tsx +++ b/web/app/components/workflow/nodes/_base/components/trigger-container.tsx @@ -28,10 +28,10 @@ const TriggerContainer: FC = ({ }, [status, customLabel, t]) return ( -
-
-
- +
+
+
+ {statusConfig.label}
diff --git a/web/i18n/en-US/workflow.ts b/web/i18n/en-US/workflow.ts index d7133bec41..ce058668ff 100644 --- a/web/i18n/en-US/workflow.ts +++ b/web/i18n/en-US/workflow.ts @@ -11,6 +11,7 @@ const translation = { publishUpdate: 'Publish Update', run: 'Test Run', running: 'Running', + chooseStartNodeToRun: 'Choose the start node to run', inRunMode: 'In Run Mode', inPreview: 'In Preview', inPreviewMode: 'In Preview Mode', diff --git a/web/i18n/ja-JP/workflow.ts b/web/i18n/ja-JP/workflow.ts index 16d3b2f789..83885d0134 100644 --- a/web/i18n/ja-JP/workflow.ts +++ b/web/i18n/ja-JP/workflow.ts @@ -11,6 +11,7 @@ const translation = { publishUpdate: '更新を公開', run: 'テスト実行', running: '実行中', + chooseStartNodeToRun: '実行する開始ノードを選択', inRunMode: '実行モード中', inPreview: 'プレビュー中', inPreviewMode: 'プレビューモード中', diff --git a/web/i18n/zh-Hans/workflow.ts b/web/i18n/zh-Hans/workflow.ts index 8c293dd6aa..d5a1f2115a 100644 --- a/web/i18n/zh-Hans/workflow.ts +++ b/web/i18n/zh-Hans/workflow.ts @@ -11,6 +11,7 @@ const translation = { publishUpdate: '发布更新', run: '测试运行', running: '运行中', + chooseStartNodeToRun: '选择启动节点进行运行', inRunMode: '在运行模式中', inPreview: '预览中', inPreviewMode: '预览中', From 6a3d135d4936e0e645e8324cd64a6bc9286fd13b Mon Sep 17 00:00:00 2001 From: lyzno1 <92089059+lyzno1@users.noreply.github.com> Date: Mon, 18 Aug 2025 23:37:57 +0800 Subject: [PATCH 006/180] fix: simplify trigger-schedule hourly mode calculation and improve UI consistency (#24082) Co-authored-by: zhangxuhe1 --- .../date-picker/index.tsx | 21 +- .../time-picker/index.tsx | 21 +- .../base/date-and-time-picker/types.ts | 2 + .../components/date-time-picker.spec.tsx | 139 ---------- .../components/date-time-picker.tsx | 158 ------------ .../components/frequency-selector.tsx | 3 +- .../components/monthly-days-selector.tsx | 8 +- .../components/next-execution-times.tsx | 4 +- .../components/time-picker.tsx | 230 ----------------- .../components/weekday-selector.tsx | 8 +- .../workflow/nodes/trigger-schedule/panel.tsx | 43 +++- .../nodes/trigger-schedule/use-config.ts | 9 + .../utils/execution-time-calculator.spec.ts | 243 +++++++++++++++++- .../utils/execution-time-calculator.ts | 22 +- 14 files changed, 320 insertions(+), 591 deletions(-) delete mode 100644 web/app/components/workflow/nodes/trigger-schedule/components/date-time-picker.spec.tsx delete mode 100644 web/app/components/workflow/nodes/trigger-schedule/components/date-time-picker.tsx delete mode 100644 web/app/components/workflow/nodes/trigger-schedule/components/time-picker.tsx diff --git a/web/app/components/base/date-and-time-picker/date-picker/index.tsx b/web/app/components/base/date-and-time-picker/date-picker/index.tsx index f99b8257c1..53cf383dad 100644 --- a/web/app/components/base/date-and-time-picker/date-picker/index.tsx +++ b/web/app/components/base/date-and-time-picker/date-picker/index.tsx @@ -36,6 +36,7 @@ const DatePicker = ({ renderTrigger, triggerWrapClassName, popupZIndexClassname = 'z-[11]', + notClearable = false, }: DatePickerProps) => { const { t } = useTranslation() const [isOpen, setIsOpen] = useState(false) @@ -200,7 +201,7 @@ const DatePicker = ({ {renderTrigger ? (renderTrigger({ @@ -224,15 +225,17 @@ const DatePicker = ({ - + {!notClearable && ( + + )}
)} diff --git a/web/app/components/base/date-and-time-picker/time-picker/index.tsx b/web/app/components/base/date-and-time-picker/time-picker/index.tsx index 8ef10abc2e..830ba4bf0b 100644 --- a/web/app/components/base/date-and-time-picker/time-picker/index.tsx +++ b/web/app/components/base/date-and-time-picker/time-picker/index.tsx @@ -23,6 +23,7 @@ const TimePicker = ({ title, minuteFilter, popupClassName, + notClearable = false, }: TimePickerProps) => { const { t } = useTranslation() const [isOpen, setIsOpen] = useState(false) @@ -123,7 +124,7 @@ const TimePicker = ({ {renderTrigger ? (renderTrigger({ @@ -139,15 +140,17 @@ const TimePicker = ({ - + {!notClearable && ( + + )}
)} diff --git a/web/app/components/base/date-and-time-picker/types.ts b/web/app/components/base/date-and-time-picker/types.ts index 4ac01c142a..68d6967c2b 100644 --- a/web/app/components/base/date-and-time-picker/types.ts +++ b/web/app/components/base/date-and-time-picker/types.ts @@ -30,6 +30,7 @@ export type DatePickerProps = { renderTrigger?: (props: TriggerProps) => React.ReactNode minuteFilter?: (minutes: string[]) => string[] popupZIndexClassname?: string + notClearable?: boolean } export type DatePickerHeaderProps = { @@ -63,6 +64,7 @@ export type TimePickerProps = { title?: string minuteFilter?: (minutes: string[]) => string[] popupClassName?: string + notClearable?: boolean } export type TimePickerFooterProps = { diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/date-time-picker.spec.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/date-time-picker.spec.tsx deleted file mode 100644 index 4d5a55029a..0000000000 --- a/web/app/components/workflow/nodes/trigger-schedule/components/date-time-picker.spec.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import React from 'react' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import DateTimePicker from './date-time-picker' - -jest.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => { - const translations: Record = { - 'workflow.nodes.triggerSchedule.selectDateTime': 'Select Date & Time', - 'common.operation.now': 'Now', - 'common.operation.ok': 'OK', - } - return translations[key] || key - }, - }), -})) - -describe('DateTimePicker', () => { - const mockOnChange = jest.fn() - - beforeEach(() => { - jest.clearAllMocks() - }) - - test('renders with default value', () => { - render() - - const button = screen.getByRole('button') - expect(button).toBeInTheDocument() - expect(button.textContent).toMatch(/\d+, \d{4} \d{1,2}:\d{2} [AP]M/) - }) - - test('renders with provided value', () => { - const testDate = new Date('2024-01-15T14:30:00.000Z') - render() - - const button = screen.getByRole('button') - expect(button).toBeInTheDocument() - }) - - test('opens picker when button is clicked', () => { - render() - - const button = screen.getByRole('button') - fireEvent.click(button) - - expect(screen.getByText('Select Date & Time')).toBeInTheDocument() - expect(screen.getByText('Now')).toBeInTheDocument() - expect(screen.getByText('OK')).toBeInTheDocument() - }) - - test('closes picker when clicking outside', () => { - render() - - const button = screen.getByRole('button') - fireEvent.click(button) - - expect(screen.getByText('Select Date & Time')).toBeInTheDocument() - - const overlay = document.querySelector('.fixed.inset-0') - fireEvent.click(overlay!) - - expect(screen.queryByText('Select Date & Time')).not.toBeInTheDocument() - }) - - test('does not call onChange when input changes without clicking OK', () => { - render() - - const button = screen.getByRole('button') - fireEvent.click(button) - - const input = screen.getByDisplayValue(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/) - fireEvent.change(input, { target: { value: '2024-12-25T15:30' } }) - - const overlay = document.querySelector('.fixed.inset-0') - fireEvent.click(overlay!) - - expect(mockOnChange).not.toHaveBeenCalled() - }) - - test('calls onChange when clicking OK button', () => { - render() - - const button = screen.getByRole('button') - fireEvent.click(button) - - const input = screen.getByDisplayValue(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/) - fireEvent.change(input, { target: { value: '2024-12-25T15:30' } }) - - const okButton = screen.getByText('OK') - fireEvent.click(okButton) - - expect(mockOnChange).toHaveBeenCalledWith(expect.stringMatching(/2024-12-25T.*:30.*Z/)) - }) - - test('calls onChange when clicking Now button', () => { - render() - - const button = screen.getByRole('button') - fireEvent.click(button) - - const nowButton = screen.getByText('Now') - fireEvent.click(nowButton) - - expect(mockOnChange).toHaveBeenCalledWith(expect.stringMatching(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/)) - }) - - test('resets temp value when reopening picker', async () => { - render() - - const button = screen.getByRole('button') - fireEvent.click(button) - - const input = screen.getByDisplayValue(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/) - const originalValue = input.getAttribute('value') - - fireEvent.change(input, { target: { value: '2024-12-25T15:30' } }) - expect(input.getAttribute('value')).toBe('2024-12-25T15:30') - - const overlay = document.querySelector('.fixed.inset-0') - fireEvent.click(overlay!) - - fireEvent.click(button) - - await waitFor(() => { - const newInput = screen.getByDisplayValue(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/) - expect(newInput.getAttribute('value')).toBe(originalValue) - }) - }) - - test('displays current value in button text', () => { - const testDate = new Date('2024-01-15T14:30:00.000Z') - render() - - const button = screen.getByRole('button') - expect(button.textContent).toMatch(/January 15, 2024/) - expect(button.textContent).toMatch(/\d{1,2}:30 [AP]M/) - }) -}) diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/date-time-picker.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/date-time-picker.tsx deleted file mode 100644 index 5c8dffadd0..0000000000 --- a/web/app/components/workflow/nodes/trigger-schedule/components/date-time-picker.tsx +++ /dev/null @@ -1,158 +0,0 @@ -import React, { useState } from 'react' -import { useTranslation } from 'react-i18next' -import { RiCalendarLine } from '@remixicon/react' -import { getDefaultDateTime } from '../utils/execution-time-calculator' - -type DateTimePickerProps = { - value?: string - onChange: (datetime: string) => void -} - -const DateTimePicker = ({ value, onChange }: DateTimePickerProps) => { - const { t } = useTranslation() - const [isOpen, setIsOpen] = useState(false) - const [tempValue, setTempValue] = useState('') - - React.useEffect(() => { - if (isOpen) - setTempValue('') - }, [isOpen]) - - const getCurrentDateTime = () => { - if (value) { - try { - const date = new Date(value) - return `${date.toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric', - })} ${date.toLocaleTimeString('en-US', { - hour: 'numeric', - minute: '2-digit', - hour12: true, - })}` - } - catch { - // fallback - } - } - - const defaultDate = getDefaultDateTime() - - return `${defaultDate.toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric', - })} ${defaultDate.toLocaleTimeString('en-US', { - hour: 'numeric', - minute: '2-digit', - hour12: true, - })}` - } - - const handleDateTimeChange = (event: React.ChangeEvent) => { - const dateTimeValue = event.target.value - setTempValue(dateTimeValue) - } - - const getInputValue = () => { - if (tempValue) - return tempValue - - if (value) { - try { - const date = new Date(value) - const year = date.getFullYear() - const month = String(date.getMonth() + 1).padStart(2, '0') - const day = String(date.getDate()).padStart(2, '0') - const hours = String(date.getHours()).padStart(2, '0') - const minutes = String(date.getMinutes()).padStart(2, '0') - return `${year}-${month}-${day}T${hours}:${minutes}` - } - catch { - // fallback - } - } - - const defaultDate = getDefaultDateTime() - const year = defaultDate.getFullYear() - const month = String(defaultDate.getMonth() + 1).padStart(2, '0') - const day = String(defaultDate.getDate()).padStart(2, '0') - const hours = String(defaultDate.getHours()).padStart(2, '0') - const minutes = String(defaultDate.getMinutes()).padStart(2, '0') - return `${year}-${month}-${day}T${hours}:${minutes}` - } - - return ( -
- - - {isOpen && ( -
-
-

{t('workflow.nodes.triggerSchedule.selectDateTime')}

-
- -
- -
- -
- -
- - -
-
- )} - - {isOpen && ( -
{ - setTempValue('') - setIsOpen(false) - }} - /> - )} -
- ) -} - -export default DateTimePicker diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/frequency-selector.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/frequency-selector.tsx index fa48a66350..2eabb6d85f 100644 --- a/web/app/components/workflow/nodes/trigger-schedule/components/frequency-selector.tsx +++ b/web/app/components/workflow/nodes/trigger-schedule/components/frequency-selector.tsx @@ -27,7 +27,8 @@ const FrequencySelector = ({ frequency, onChange }: FrequencySelectorProps) => { defaultValue={frequency} onSelect={item => onChange(item.value as ScheduleFrequency)} placeholder={t('workflow.nodes.triggerSchedule.selectFrequency')} - className="w-full" + className="w-full py-2" + wrapperClassName="h-auto" optionWrapClassName="min-w-40" notClearable={true} allowSearch={false} diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/monthly-days-selector.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/monthly-days-selector.tsx index 4c9c8b75b6..68936bf253 100644 --- a/web/app/components/workflow/nodes/trigger-schedule/components/monthly-days-selector.tsx +++ b/web/app/components/workflow/nodes/trigger-schedule/components/monthly-days-selector.tsx @@ -22,7 +22,7 @@ const MonthlyDaysSelector = ({ selectedDay, onChange }: MonthlyDaysSelectorProps return (
-