From 22182d4fe26eea5da1a9e92ce7a6e84e6d69d583 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Mon, 23 Feb 2026 16:57:40 +0000 Subject: [PATCH] feat: collapse groups as single graph nodes like subflows Co-Authored-By: Claude Opus 4.6 --- backend/windmill-types/src/flows.rs | 16 ++ .../lib/components/graph/FlowGraphV2.svelte | 16 +- .../lib/components/graph/GroupOverlay.svelte | 215 +++++++++--------- .../components/graph/graphBuilder.svelte.ts | 84 ++++++- .../components/graph/groupEditor.svelte.ts | 7 + .../renderers/nodes/CollapsedGroupNode.svelte | 41 ++++ openflow.openapi.yaml | 3 + 7 files changed, 267 insertions(+), 115 deletions(-) create mode 100644 frontend/src/lib/components/graph/renderers/nodes/CollapsedGroupNode.svelte diff --git a/backend/windmill-types/src/flows.rs b/backend/windmill-types/src/flows.rs index 2d893dea10..631f13c8aa 100644 --- a/backend/windmill-types/src/flows.rs +++ b/backend/windmill-types/src/flows.rs @@ -178,6 +178,8 @@ pub struct FlowValue { pub chat_input_enabled: Option, #[serde(skip_serializing_if = "Option::is_none")] pub flow_env: Option>>, + #[serde(skip_serializing_if = "Option::is_none")] + pub groups: Option>, } impl FlowValue { @@ -404,6 +406,20 @@ pub struct Mock { pub return_value: Option, } +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct FlowGroup { + pub id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub summary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub collapsed: Option, + pub module_ids: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub color: Option, +} + #[derive(Deserialize, Serialize, Debug, Clone, Default)] pub struct FlowModule { #[serde(default = "default_id")] diff --git a/frontend/src/lib/components/graph/FlowGraphV2.svelte b/frontend/src/lib/components/graph/FlowGraphV2.svelte index 3674f2a6a8..51722e93a5 100644 --- a/frontend/src/lib/components/graph/FlowGraphV2.svelte +++ b/frontend/src/lib/components/graph/FlowGraphV2.svelte @@ -61,6 +61,7 @@ import AiToolNode, { computeAIToolNodes } from './renderers/nodes/AIToolNode.svelte' import NewAiToolNode from './renderers/nodes/NewAIToolNode.svelte' import NoteNode from './renderers/nodes/NoteNode.svelte' + import CollapsedGroupNode from './renderers/nodes/CollapsedGroupNode.svelte' import NoteTool from './NoteTool.svelte' import SelectionBoundingBox from './SelectionBoundingBox.svelte' import GroupOverlay from './GroupOverlay.svelte' @@ -467,6 +468,9 @@ delete expandedSubflows[id] expandedSubflows = expandedSubflows }, + expandGroup: (groupId: string) => { + groupEditorContext?.groupEditor.updateCollapsedDefault(groupId, false) + }, updateMock: (detail) => { onUpdateMock?.(detail) }, @@ -760,7 +764,8 @@ assetsOverflowed: AssetsOverflowedNode, aiTool: AiToolNode, newAiTool: NewAiToolNode, - note: NoteNode + note: NoteNode, + collapsedGroup: CollapsedGroupNode } as any const edgeTypes = { @@ -796,6 +801,12 @@ let graph = $derived.by(() => { moduleTracker.counter effectiveModuleActions + currentGroups + + const collapsedGroups = (groupEditorContext?.groupEditor.getGroups() ?? []).filter( + (g) => g.collapsed === true + ) + return graphBuilder( untrack(() => effectiveModules), { @@ -828,7 +839,8 @@ untrack(() => selectedId), simplifiableFlow, triggerNode ? path : undefined, - expandedSubflows + expandedSubflows, + collapsedGroups ) }) let hideAssetsToggle = $derived( diff --git a/frontend/src/lib/components/graph/GroupOverlay.svelte b/frontend/src/lib/components/graph/GroupOverlay.svelte index 26ace9eb81..e6e5632faa 100644 --- a/frontend/src/lib/components/graph/GroupOverlay.svelte +++ b/frontend/src/lib/components/graph/GroupOverlay.svelte @@ -1,7 +1,7 @@ {#each allGroups as group (group.id)} - {@const bounds = computeGroupBounds(group)} - {#if bounds} - - -
- + {#if !group.collapsed} + {@const bounds = computeGroupBounds(group)} + {#if bounds} + +
{ - if (hideTimeout) { - clearTimeout(hideTimeout) - hideTimeout = undefined - } - hoveredLabelGroupId = group.id - visibleGroup = group - if (group.id && !(group.id in collapsedState)) { - collapsedState[group.id] = group.collapsed ?? false - } - }} - onpointerleave={() => { - hoveredLabelGroupId = null - if (!colorPickerOpen && !actionBarHovered && !editingSummary) { - hideTimeout = setTimeout(() => { - visibleGroup = undefined - }, 150) - } - }} + class="absolute rounded-lg border pointer-events-none {getBorderColorClass(group.color)}" + style:transform="translate({bounds.x}px, {bounds.y}px)" + style:width="{bounds.width}px" + style:height="{bounds.height}px" + style:z-index="4" > - {#if editingGroupId === group.id} - commitSummary(group.id)} - onkeydown={(e) => { - if (e.key === 'Enter') commitSummary(group.id) - if (e.key === 'Escape') { - editingGroupId = null - } - }} - autofocus - /> - {:else} - - {group.summary || 'Group'} - - {#if editMode && hoveredLabelGroupId === group.id} - - {/if} - {/if} -
- - - {#if editMode && visibleGroup?.id === group.id} +
{ - actionBarHovered = true if (hideTimeout) { clearTimeout(hideTimeout) hideTimeout = undefined } + hoveredLabelGroupId = group.id + visibleGroup = group }} onpointerleave={() => { - actionBarHovered = false + hoveredLabelGroupId = null + if (!colorPickerOpen && !actionBarHovered && !editingSummary) { + hideTimeout = setTimeout(() => { + visibleGroup = undefined + }, 150) + } }} > -
-
+ {:else} + + {group.summary || 'Group'} + + {#if editMode && hoveredLabelGroupId === group.id} + + {/if} + {/if}
- {/if} -
-
+ + + {#if editMode && visibleGroup?.id === group.id} +
{ + actionBarHovered = true + if (hideTimeout) { + clearTimeout(hideTimeout) + hideTimeout = undefined + } + }} + onpointerleave={() => { + actionBarHovered = false + }} + > +
+
+
+ {/if} + + + {/if} {/if} {/each} diff --git a/frontend/src/lib/components/graph/graphBuilder.svelte.ts b/frontend/src/lib/components/graph/graphBuilder.svelte.ts index 9b578030e2..60468f2471 100644 --- a/frontend/src/lib/components/graph/graphBuilder.svelte.ts +++ b/frontend/src/lib/components/graph/graphBuilder.svelte.ts @@ -62,6 +62,7 @@ export type GraphEventHandlers = { simplifyFlow: (b: boolean) => void expandSubflow: (id: string, path: string) => void minimizeSubflow: (id: string) => void + expandGroup: (groupId: string) => void updateMock: (detail: { mock: FlowModule['mock']; id: string }) => void testUpTo: (id: string) => void editInput: (moduleId: string, key: string) => void @@ -113,6 +114,7 @@ export type FlowNode = | AssetsOverflowedN | AiToolN | NewAiToolN + | CollapsedGroupN export type InputN = { type: 'input2' @@ -329,6 +331,19 @@ export type NewAiToolN = { } } +export type CollapsedGroupN = { + type: 'collapsedGroup' + data: { + offset: number + groupId: string + summary: string | undefined + description: string | undefined + color: string | undefined + stepCount: number + eventHandlers: GraphEventHandlers + } +} + export function topologicalSort( nodes: { id: string; parentIds?: string[] }[] ): { id: string; parentIds?: string[] }[] { @@ -396,7 +411,8 @@ export function graphBuilder( selectedId: string | undefined, simplifiableFlow: SimplifiableFlow | undefined, flowPathForTriggerNode: string | undefined, - expandedSubflows: Record + expandedSubflows: Record, + collapsedGroups: Array<{ id: string; summary?: string; description?: string; color?: string; collapsed?: boolean; module_ids: string[] }> // triggerProps?: { // path?: string // flowIsSimplifiable?: boolean @@ -413,6 +429,15 @@ export function graphBuilder( return { nodes: {}, edges: [] } } + // Build a map: module_id -> collapsed group (only for collapsed groups) + const moduleToCollapsedGroup = new Map() + for (const group of collapsedGroups) { + for (const moduleId of group.module_ids) { + moduleToCollapsedGroup.set(moduleId, group) + } + } + const emittedCollapsedGroups = new Set() + const nodes: NodeLayout[] = [] const edges: Edge[] = [] @@ -634,6 +659,63 @@ export function graphBuilder( } } else { modules.forEach((module, index) => { + // --- Collapsed group handling --- + const collapsedGroup = moduleToCollapsedGroup.get(module.id) + if (collapsedGroup) { + if (emittedCollapsedGroups.has(collapsedGroup.id)) { + // Already emitted — skip entirely, but wire final edge if last module + if (index === modules.length - 1 && previousId && nextNode) { + addEdge(previousId, nextNode.id, branch, prefix, { + subModules: modules, + disableMoveIds + }) + } + return + } + + // First member — emit placeholder node + emittedCollapsedGroups.add(collapsedGroup.id) + const nodeId = `collapsed-group:${collapsedGroup.id}` + nodes.push({ + id: nodeId, + type: 'collapsedGroup', + data: { + offset: currentOffset, + groupId: collapsedGroup.id, + summary: collapsedGroup.summary, + description: collapsedGroup.description, + color: collapsedGroup.color, + stepCount: collapsedGroup.module_ids.length, + eventHandlers + } + } as NodeLayout) + + // Edge from previous → group node + if (index === 0) { + addEdge(beforeNode.id, nodeId, undefined, prefix, { + subModules: modules, + disableMoveIds, + disableInsert: simplifiedTriggerView + }) + } else if (previousId) { + addEdge(previousId, nodeId, branch, prefix, { + subModules: modules, + disableMoveIds + }) + } + previousId = nodeId + + // Final edge if last module + if (index === modules.length - 1 && nextNode) { + addEdge(nodeId, nextNode.id, branch, prefix, { + subModules: modules, + disableMoveIds + }) + } + return + } + // --- End collapsed group handling --- + const localDisableMoveIds = [...disableMoveIds, module.id] // Add the edge between the previous node and the current one diff --git a/frontend/src/lib/components/graph/groupEditor.svelte.ts b/frontend/src/lib/components/graph/groupEditor.svelte.ts index 3420e889bf..275b55b4f0 100644 --- a/frontend/src/lib/components/graph/groupEditor.svelte.ts +++ b/frontend/src/lib/components/graph/groupEditor.svelte.ts @@ -11,6 +11,7 @@ import { getContext, setContext } from 'svelte' export type FlowGroup = { id: string summary?: string + description?: string collapsed?: boolean module_ids: Array color?: string @@ -70,6 +71,7 @@ export class GroupEditor { const newGroup: FlowGroup = { id: generateId(), + description: '', module_ids: filteredIds, color } @@ -92,6 +94,11 @@ export class GroupEditor { this.setGroups(groups.map((g) => (g.id === groupId ? { ...g, summary } : g))) } + updateDescription(groupId: string, description: string): void { + const groups = this.getGroups() + this.setGroups(groups.map((g) => (g.id === groupId ? { ...g, description } : g))) + } + updateCollapsedDefault(groupId: string, collapsed: boolean): void { const groups = this.getGroups() this.setGroups(groups.map((g) => (g.id === groupId ? { ...g, collapsed } : g))) diff --git a/frontend/src/lib/components/graph/renderers/nodes/CollapsedGroupNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/CollapsedGroupNode.svelte new file mode 100644 index 0000000000..f029571e83 --- /dev/null +++ b/frontend/src/lib/components/graph/renderers/nodes/CollapsedGroupNode.svelte @@ -0,0 +1,41 @@ + + + + {#snippet children({ darkMode })} + +
+ +
+ {/snippet} +
diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index afbfc14477..df1d18df93 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -224,6 +224,9 @@ components: summary: type: string description: Display name for this group + description: + type: string + description: Markdown description shown when the group is collapsed collapsed: type: boolean default: false