diff --git a/web/app/components/workflow/hooks/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts index 6addd299b9..8866ff622b 100644 --- a/web/app/components/workflow/hooks/use-nodes-interactions.ts +++ b/web/app/components/workflow/hooks/use-nodes-interactions.ts @@ -2529,6 +2529,77 @@ export const useNodesInteractions = () => { }) }, [handleSyncWorkflowDraft, saveStateToHistory, store, t, workflowStore]) + // check if the current selection can be ungrouped (single selected Group node) + const getCanUngroup = useCallback(() => { + const { getNodes } = store.getState() + const nodes = getNodes() + const selectedNodes = nodes.filter(node => node.selected) + + if (selectedNodes.length !== 1) + return false + + return selectedNodes[0].data.type === BlockEnum.Group + }, [store]) + + // get the selected group node id for ungroup operation + const getSelectedGroupId = useCallback(() => { + const { getNodes } = store.getState() + const nodes = getNodes() + const selectedNodes = nodes.filter(node => node.selected) + + if (selectedNodes.length === 1 && selectedNodes[0].data.type === BlockEnum.Group) + return selectedNodes[0].id + + return undefined + }, [store]) + + const handleUngroup = useCallback((groupId: string) => { + const { getNodes, setNodes, edges, setEdges } = store.getState() + const nodes = getNodes() + const groupNode = nodes.find(n => n.id === groupId) + + if (!groupNode || groupNode.data.type !== BlockEnum.Group) + return + + const memberIds = new Set((groupNode.data.members || []).map((m: { id: string }) => m.id)) + + // restore hidden member nodes + const newNodes = produce(nodes, (draft) => { + draft.forEach((node) => { + if (memberIds.has(node.id)) { + node.hidden = false + delete node.data._hiddenInGroupId + } + }) + // remove group node + const groupIndex = draft.findIndex(n => n.id === groupId) + if (groupIndex !== -1) + draft.splice(groupIndex, 1) + }) + + // restore hidden edges and remove temp edges + const newEdges = produce(edges, (draft) => { + // restore hidden edges that involve member nodes + draft.forEach((edge) => { + if (edge.hidden && (memberIds.has(edge.source) || memberIds.has(edge.target))) + edge.hidden = false + }) + // remove temp edges connected to group (iterate backwards to safely splice) + for (let i = draft.length - 1; i >= 0; i--) { + const edge = draft[i] + if (edge.data?._isTemp && (edge.source === groupId || edge.target === groupId)) + draft.splice(i, 1) + } + }) + + setNodes(newNodes) + setEdges(newEdges) + handleSyncWorkflowDraft() + saveStateToHistory(WorkflowHistoryEvent.NodeDelete, { + nodeId: groupId, + }) + }, [handleSyncWorkflowDraft, saveStateToHistory, store]) + return { handleNodeDragStart, handleNodeDrag, @@ -2550,6 +2621,7 @@ export const useNodesInteractions = () => { handleNodesDuplicate, handleNodesDelete, handleMakeGroup, + handleUngroup, handleNodeResize, handleNodeDisconnect, handleHistoryBack, @@ -2558,5 +2630,7 @@ export const useNodesInteractions = () => { undimAllNodes, hasBundledNodes, getCanMakeGroup, + getCanUngroup, + getSelectedGroupId, } } diff --git a/web/app/components/workflow/hooks/use-shortcuts.ts b/web/app/components/workflow/hooks/use-shortcuts.ts index e9479473d9..0845dbd2d9 100644 --- a/web/app/components/workflow/hooks/use-shortcuts.ts +++ b/web/app/components/workflow/hooks/use-shortcuts.ts @@ -30,6 +30,9 @@ export const useShortcuts = (): void => { hasBundledNodes, getCanMakeGroup, handleMakeGroup, + getCanUngroup, + getSelectedGroupId, + handleUngroup, } = useNodesInteractions() const { shortcutsEnabled: workflowHistoryShortcutsEnabled } = useWorkflowHistoryStore() const { handleSyncWorkflowDraft } = useNodesSyncDraft() @@ -113,6 +116,16 @@ export const useShortcuts = (): void => { } }, { exactMatch: true, useCapture: true }) + useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.g`, (e) => { + // Only intercept when the selection can be ungrouped + if (shouldHandleShortcut(e) && getCanUngroup()) { + e.preventDefault() + const groupId = getSelectedGroupId() + if (groupId) + handleUngroup(groupId) + } + }, { exactMatch: true, useCapture: true }) + useKeyPress(`${getKeyboardKeyCodeBySystem('alt')}.r`, (e) => { if (shouldHandleShortcut(e)) { e.preventDefault() diff --git a/web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx b/web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx index b460aa651c..c095f7fcb3 100644 --- a/web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx +++ b/web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx @@ -41,13 +41,14 @@ const PanelOperatorPopup = ({ handleNodesDuplicate, handleNodeSelect, handleNodesCopy, + handleUngroup, } = useNodesInteractions() const { handleNodeDataUpdate } = useNodeDataUpdate() const { handleSyncWorkflowDraft } = useNodesSyncDraft() const { nodesReadOnly } = useNodesReadOnly() const edge = edges.find(edge => edge.target === id) const nodeMetaData = useNodeMetaData({ id, data } as Node) - const showChangeBlock = !nodeMetaData.isTypeFixed && !nodesReadOnly + const showChangeBlock = !nodeMetaData.isTypeFixed && !nodesReadOnly && data.type !== BlockEnum.Group const isChildNode = !!(data.isInIteration || data.isInLoop) const { data: workflowTools } = useAllWorkflowTools() @@ -61,6 +62,25 @@ const PanelOperatorPopup = ({ return (
+ { + !nodesReadOnly && data.type === BlockEnum.Group && ( + <> +
+
{ + onClosePopup() + handleUngroup(id) + }} + > + {t('panel.ungroup', { ns: 'workflow' })} + +
+
+
+ + ) + } { (showChangeBlock || canRunBySingle(data.type, isChildNode)) && ( <> diff --git a/web/i18n/en-US/workflow.json b/web/i18n/en-US/workflow.json index 92396199d1..11700722d3 100644 --- a/web/i18n/en-US/workflow.json +++ b/web/i18n/en-US/workflow.json @@ -967,6 +967,7 @@ "panel.scrollToSelectedNode": "Scroll to selected node", "panel.selectNextStep": "Select Next Step", "panel.startNode": "Start Node", + "panel.ungroup": "Ungroup", "panel.userInputField": "User Input Field", "publishLimit.startNodeDesc": "You’ve reached the limit of 2 triggers per workflow for this plan. Upgrade to publish this workflow.", "publishLimit.startNodeTitlePrefix": "Upgrade to", diff --git a/web/i18n/ja-JP/workflow.json b/web/i18n/ja-JP/workflow.json index df8fb56dd0..dd811a3858 100644 --- a/web/i18n/ja-JP/workflow.json +++ b/web/i18n/ja-JP/workflow.json @@ -964,6 +964,7 @@ "panel.scrollToSelectedNode": "選択したノードまでスクロール", "panel.selectNextStep": "次ノード選択", "panel.startNode": "開始ノード", + "panel.ungroup": "グループ解除", "panel.userInputField": "ユーザー入力欄", "publishLimit.startNodeDesc": "このプランでは、各ワークフローのトリガー数は最大 2 個まで設定できます。公開するにはアップグレードが必要です。", "publishLimit.startNodeTitlePrefix": "アップグレードして、", diff --git a/web/i18n/zh-Hans/workflow.json b/web/i18n/zh-Hans/workflow.json index 30a380a032..569407aafa 100644 --- a/web/i18n/zh-Hans/workflow.json +++ b/web/i18n/zh-Hans/workflow.json @@ -965,6 +965,7 @@ "panel.scrollToSelectedNode": "滚动至选中节点", "panel.selectNextStep": "选择下一个节点", "panel.startNode": "开始节点", + "panel.ungroup": "取消编组", "panel.userInputField": "用户输入字段", "publishLimit.startNodeDesc": "您已达到此计划上每个工作流最多 2 个触发器的限制。请升级后再发布此工作流。", "publishLimit.startNodeTitlePrefix": "升级以", diff --git a/web/i18n/zh-Hant/workflow.json b/web/i18n/zh-Hant/workflow.json index b16ba1fcd9..80e72e42ff 100644 --- a/web/i18n/zh-Hant/workflow.json +++ b/web/i18n/zh-Hant/workflow.json @@ -964,6 +964,7 @@ "panel.scrollToSelectedNode": "捲動至選取的節點", "panel.selectNextStep": "選擇下一個節點", "panel.startNode": "起始節點", + "panel.ungroup": "取消群組", "panel.userInputField": "用戶輸入字段", "publishLimit.startNodeDesc": "目前方案最多允許 2 個開始節點,升級後才能發布此工作流程。", "publishLimit.startNodeTitlePrefix": "升級以",