mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-11 16:09:39 +00:00
feat: collapse groups as single graph nodes like subflows
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
b862a3dc62
commit
22182d4fe2
@@ -178,6 +178,8 @@ pub struct FlowValue {
|
||||
pub chat_input_enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub flow_env: Option<HashMap<String, Box<RawValue>>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub groups: Option<Vec<FlowGroup>>,
|
||||
}
|
||||
|
||||
impl FlowValue {
|
||||
@@ -404,6 +406,20 @@ pub struct Mock {
|
||||
pub return_value: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||
pub struct FlowGroup {
|
||||
pub id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub summary: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub collapsed: Option<bool>,
|
||||
pub module_ids: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub color: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone, Default)]
|
||||
pub struct FlowModule {
|
||||
#[serde(default = "default_id")]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { ViewportPortal, type Node } from '@xyflow/svelte'
|
||||
import { calculateNodesBoundsWithOffset } from './util'
|
||||
import { ChevronDown, ChevronRight, Pen, X } from 'lucide-svelte'
|
||||
import { ChevronDown, Pen, X } from 'lucide-svelte'
|
||||
import { getGroupEditorContext, type FlowGroup } from './groupEditor.svelte'
|
||||
import { NoteColor, NOTE_COLORS } from './noteColors'
|
||||
import NoteColorPicker from './NoteColorPicker.svelte'
|
||||
@@ -17,9 +17,6 @@
|
||||
|
||||
const groupEditorContext = getGroupEditorContext()
|
||||
|
||||
// Runtime collapse state, keyed by group ID
|
||||
let collapsedState: Record<string, boolean> = $state({})
|
||||
|
||||
// Color picker open state
|
||||
let colorPickerOpen = $state(false)
|
||||
|
||||
@@ -44,11 +41,6 @@
|
||||
hideTimeout = undefined
|
||||
}
|
||||
visibleGroup = activeGroup
|
||||
|
||||
// Initialize collapse state from persisted value if not already set
|
||||
if (activeGroup.id && !(activeGroup.id in collapsedState)) {
|
||||
collapsedState[activeGroup.id] = activeGroup.collapsed ?? false
|
||||
}
|
||||
} else if (!colorPickerOpen && !actionBarHovered && !editingSummary) {
|
||||
hideTimeout = setTimeout(() => {
|
||||
visibleGroup = undefined
|
||||
@@ -103,12 +95,12 @@
|
||||
)
|
||||
}
|
||||
|
||||
function isCollapsed(groupId: string): boolean {
|
||||
return collapsedState[groupId] ?? false
|
||||
}
|
||||
|
||||
function toggleCollapse(groupId: string) {
|
||||
collapsedState[groupId] = !isCollapsed(groupId)
|
||||
const current =
|
||||
groupEditorContext?.groupEditor
|
||||
.getGroups()
|
||||
.find((g) => g.id === groupId)?.collapsed ?? false
|
||||
groupEditorContext?.groupEditor.updateCollapsedDefault(groupId, !current)
|
||||
}
|
||||
|
||||
// Label hover state (for showing pen button)
|
||||
@@ -132,118 +124,117 @@
|
||||
</script>
|
||||
|
||||
{#each allGroups as group (group.id)}
|
||||
{@const bounds = computeGroupBounds(group)}
|
||||
{#if bounds}
|
||||
<ViewportPortal target="front">
|
||||
<!-- Always-visible border (no bg, solid 1px) -->
|
||||
<div
|
||||
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"
|
||||
>
|
||||
<!-- Label (top-left, above the border) -->
|
||||
{#if !group.collapsed}
|
||||
{@const bounds = computeGroupBounds(group)}
|
||||
{#if bounds}
|
||||
<ViewportPortal target="front">
|
||||
<!-- Always-visible border (no bg, solid 1px) -->
|
||||
<div
|
||||
class="absolute -top-6 left-0 flex items-center gap-1 h-5"
|
||||
style="pointer-events: auto; cursor: default;"
|
||||
onpointerenter={() => {
|
||||
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}
|
||||
<input
|
||||
class="text-xs font-medium bg-transparent border-none outline-none {getTextColorClass(group.color)} w-24"
|
||||
bind:value={summaryInput}
|
||||
onblur={() => commitSummary(group.id)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') commitSummary(group.id)
|
||||
if (e.key === 'Escape') {
|
||||
editingGroupId = null
|
||||
}
|
||||
}}
|
||||
autofocus
|
||||
/>
|
||||
{:else}
|
||||
<span class="text-xs font-medium {getTextColorClass(group.color)}">
|
||||
{group.summary || 'Group'}
|
||||
</span>
|
||||
{#if editMode && hoveredLabelGroupId === group.id}
|
||||
<button
|
||||
class="flex items-center justify-center w-4 h-4 rounded hover:bg-surface-hover {getTextColorClass(group.color)} opacity-60 hover:opacity-100"
|
||||
onclick={() => startEditSummary(group)}
|
||||
title="Edit group name"
|
||||
>
|
||||
<Pen size={10} />
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Action bar (top-right, hover only) — matches group note style -->
|
||||
{#if editMode && visibleGroup?.id === group.id}
|
||||
<!-- Label (top-left, above the border) -->
|
||||
<div
|
||||
class="absolute -top-7 right-0 p-1 h-7 group flex justify-end"
|
||||
style="pointer-events: auto;"
|
||||
class="absolute -top-6 left-0 flex items-center gap-1 h-5"
|
||||
style="pointer-events: auto; cursor: default;"
|
||||
onpointerenter={() => {
|
||||
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)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-row gap-2 h-fit">
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="xs"
|
||||
iconOnly
|
||||
title={isCollapsed(group.id) ? 'Expand group' : 'Collapse group'}
|
||||
startIcon={{ icon: isCollapsed(group.id) ? ChevronRight : ChevronDown }}
|
||||
onclick={() => toggleCollapse(group.id)}
|
||||
/>
|
||||
<NoteColorPicker
|
||||
selectedColor={(group.color as NoteColor) ?? NoteColor.BLUE}
|
||||
onColorChange={(color) => {
|
||||
groupEditorContext?.groupEditor.updateColor(group.id, color)
|
||||
{#if editingGroupId === group.id}
|
||||
<input
|
||||
class="text-xs font-medium bg-transparent border-none outline-none {getTextColorClass(group.color)} w-24"
|
||||
bind:value={summaryInput}
|
||||
onblur={() => commitSummary(group.id)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') commitSummary(group.id)
|
||||
if (e.key === 'Escape') {
|
||||
editingGroupId = null
|
||||
}
|
||||
}}
|
||||
bind:isOpen={colorPickerOpen}
|
||||
autofocus
|
||||
/>
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="xs"
|
||||
title="Delete group"
|
||||
startIcon={{ icon: X }}
|
||||
onclick={() => {
|
||||
groupEditorContext?.groupEditor.deleteGroup(group.id)
|
||||
visibleGroup = undefined
|
||||
}}
|
||||
iconOnly
|
||||
destructive
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<span class="text-xs font-medium {getTextColorClass(group.color)}">
|
||||
{group.summary || 'Group'}
|
||||
</span>
|
||||
{#if editMode && hoveredLabelGroupId === group.id}
|
||||
<button
|
||||
class="flex items-center justify-center w-4 h-4 rounded hover:bg-surface-hover {getTextColorClass(group.color)} opacity-60 hover:opacity-100"
|
||||
onclick={() => startEditSummary(group)}
|
||||
title="Edit group name"
|
||||
>
|
||||
<Pen size={10} />
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</ViewportPortal>
|
||||
|
||||
<!-- Action bar (top-right, hover only) — matches group note style -->
|
||||
{#if editMode && visibleGroup?.id === group.id}
|
||||
<div
|
||||
class="absolute -top-7 right-0 p-1 h-7 group flex justify-end"
|
||||
style="pointer-events: auto;"
|
||||
onpointerenter={() => {
|
||||
actionBarHovered = true
|
||||
if (hideTimeout) {
|
||||
clearTimeout(hideTimeout)
|
||||
hideTimeout = undefined
|
||||
}
|
||||
}}
|
||||
onpointerleave={() => {
|
||||
actionBarHovered = false
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-row gap-2 h-fit">
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="xs"
|
||||
iconOnly
|
||||
title="Collapse group"
|
||||
startIcon={{ icon: ChevronDown }}
|
||||
onclick={() => toggleCollapse(group.id)}
|
||||
/>
|
||||
<NoteColorPicker
|
||||
selectedColor={(group.color as NoteColor) ?? NoteColor.BLUE}
|
||||
onColorChange={(color) => {
|
||||
groupEditorContext?.groupEditor.updateColor(group.id, color)
|
||||
}}
|
||||
bind:isOpen={colorPickerOpen}
|
||||
/>
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="xs"
|
||||
title="Delete group"
|
||||
startIcon={{ icon: X }}
|
||||
onclick={() => {
|
||||
groupEditorContext?.groupEditor.deleteGroup(group.id)
|
||||
visibleGroup = undefined
|
||||
}}
|
||||
iconOnly
|
||||
destructive
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</ViewportPortal>
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
@@ -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<string, FlowModule[]>
|
||||
expandedSubflows: Record<string, FlowModule[]>,
|
||||
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<string, (typeof collapsedGroups)[number]>()
|
||||
for (const group of collapsedGroups) {
|
||||
for (const moduleId of group.module_ids) {
|
||||
moduleToCollapsedGroup.set(moduleId, group)
|
||||
}
|
||||
}
|
||||
const emittedCollapsedGroups = new Set<string>()
|
||||
|
||||
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
|
||||
|
||||
@@ -11,6 +11,7 @@ import { getContext, setContext } from 'svelte'
|
||||
export type FlowGroup = {
|
||||
id: string
|
||||
summary?: string
|
||||
description?: string
|
||||
collapsed?: boolean
|
||||
module_ids: Array<string>
|
||||
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)))
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import { preventDefault, stopPropagation } from 'svelte/legacy'
|
||||
import VirtualItem from '$lib/components/flows/map/VirtualItem.svelte'
|
||||
import NodeWrapper from './NodeWrapper.svelte'
|
||||
import { Maximize2 } from 'lucide-svelte'
|
||||
import type { CollapsedGroupN } from '../../graphBuilder.svelte'
|
||||
import { getGraphContext } from '../../graphContext'
|
||||
|
||||
interface Props {
|
||||
data: CollapsedGroupN['data']
|
||||
id: string
|
||||
}
|
||||
|
||||
let { data, id }: Props = $props()
|
||||
|
||||
const { selectionManager } = getGraphContext()
|
||||
</script>
|
||||
|
||||
<NodeWrapper offset={data.offset}>
|
||||
{#snippet children({ darkMode })}
|
||||
<VirtualItem
|
||||
label={data.summary || 'Group'}
|
||||
preLabel={`${data.stepCount} steps`}
|
||||
selectable
|
||||
selected={selectionManager && selectionManager.isNodeSelected(id)}
|
||||
/>
|
||||
<div class="z-50 absolute -top-4 right-11 rounded-md text-primary bg-surface">
|
||||
<button
|
||||
title="Expand group"
|
||||
class="rounded-md center-center text-primary hover:bg-surface-tertiary shadow-md p-1 duration-0"
|
||||
onclick={stopPropagation(
|
||||
preventDefault(() => {
|
||||
data.eventHandlers.expandGroup(data.groupId)
|
||||
})
|
||||
)}
|
||||
>
|
||||
<Maximize2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
{/snippet}
|
||||
</NodeWrapper>
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user