mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-07 00:01:49 +00:00
* feat: add flow group nodes core infrastructure Add group data model (start_id/end_id boundary pairs), GroupEditor for CRUD operations, groupDetectionUtils for membership computation and validation, GroupedModulesProxy for reactive sync, and compound layout support. Update openflow.openapi.yaml with group schema. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add group UI components and rendering Add GroupOverlay with bounding box and z-ordering, GroupHeader with StepCountTab and ellipsis menu, GroupNodeCard, GroupNoteArea for inline markdown notes, CollapsedGroupNode/CollapsedSubflowNode for collapsed rendering, GroupEndNode/GroupHeadNode boundary markers, and group actions in NodeContextMenu and SelectionBoundingBox. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: integrate groups into flow graph, builder, and existing components Wire group support into FlowGraphV2 (overlays, collapsed rendering, group-aware layout), graphBuilder (GroupedModule tree, container collapse/expand, group boundary nodes), BaseEdge (drop targets for group operations), ModuleNode (collapsed container rendering), and flow map components (schema item grouping). Remove SubflowBound in favor of CollapsedSubflowNode. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: remove banned $bindable(default) pattern and dead ternary Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: decouple collapse state from grouped module tree Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: pass groups prop to FlowGraphV2 and use GroupDisplayState via graphContext Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: remove group membership system, compute nesting depth from visual bounds Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: simplify GroupOverlay bounds, remove unused headerY and showNotes prop Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: populate innerNodeIds for expanded subflow overlay Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: remove expanded subflow overlay feature for separate PR Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: flatten groups in getContainerModules to prevent crash on collapsed containers Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add drag-to-move support for group nodes Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: derive group boundaries from expanded membership to prevent splitting existing groups Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: catch group validation errors and display as flow graph alert Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: add unit tests for group validation in buildGroupedModules Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: reject virtual nodes (Input, Result, Trigger) from groups Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: add virtual node rejection tests for buildGroupedModules Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: exclude preprocessor and failure module from groups Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: disable Create group button when preprocessor is selected Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: reject selection entirely when it contains excluded nodes Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: remove unnecessary excludeIds from buildGroupedModules Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: remove debug console.log from FlowGraphV2 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use cross-browser CSS grid trick for group summary input auto-sizing Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: hide group boundary edges and reformat GroupNoteArea Hide edges between group header and first node, and between last node and group-end, keeping them in the DOM but visually hidden. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: stop FlowGraphV2 from reading groups via groupEditorContext Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: show module previews with status, selection, and suspend popover in collapsed groups Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: extract collapsible implicit containers to separate branch Remove collapse/expand functionality for implicit containers (forloops, while loops, branches) from this branch. Backed up as collapsible-implicit-containers-backup for later rebase. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: use original reactive modules for graph node data instead of proxy snapshots Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: prevent node loss when moving into forloop inside a group Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: replace GroupedModule proxy with structure-only FlowStructureNode tree Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use "group-" prefix for group IDs instead of "note-" Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: update group boundaries when renaming a module ID When a module at a group boundary (start_id or end_id) is renamed, the group definitions now get updated before the reactive rebuild, preventing stale references that would break the flow structure. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: update graph layout when removing a group note Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: add opaque background behind test run button to prevent see-through Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: detect and reject duplicate group IDs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: simplify group creation validation with early marker normalization Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use $state.raw in MiniFlowGraph to avoid xyflow performance warning Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: address code review feedback - Revert backend traverse_modules change (not part of this feature) - Use Map for node lookup in GroupOverlay (O(1) vs O(n) per group) - Extract computeNodeExtraSpace to nodeExtraSpace.ts for testability Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: address PR review feedback - Compute group depths from structure tree O(n) instead of O(n²) bounds comparison - Remove unnecessary $derived(groups) in GroupOverlay - Remove unused collapsed field from container types in OpenAPI spec - Use NODE.width constant in GroupNodeCard instead of hardcoded 275px - Add comment explaining intentional stale preservation in rebuild() Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: preserve flow groups during dependency job re-serialization Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: resolve Svelte state_referenced_locally warnings in GroupHeader and FlowGraphV2 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: show subflow groups when expanding a subflow in the graph - Store both modules and groups when expanding a subflow - Pass groups to buildStructureTree so group nodes render - Include subflow groups in overlay rendering and collapse tracking - Clone modules for prefix rewriting to avoid state_unsafe_mutation - Register expanded subflow modules in moduleMap before prefix rewriting - Disable group editing in expanded subflows and read-only views Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: restore accidentally removed code from main - Restore subflowBound selection handling in selectionUtils - Restore comments in SelectionBoundingBox - Restore deletable={false} in FirstStepInputs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: remove redundant adjacency check from MoveManager The disableMoveIds check already prevents all invalid drop targets, making the adjacencySourceId/adjacencyTargetId fields unnecessary. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: regenerate auto-generated files after OpenAPI schema change Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: regenerate cli skills after main merge Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: include groups in view_graph localStorage state Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: centralize canCreateGroup and replace group note with group creation - Add canCreateGroup StateStore to GroupEditorContext, computed in FlowGraphV2 - Replace "Create group note" with "Create group" in FlowSelectionPanel - Remove "Add note" from selection bounding box dropdown - Remove unused NodeContextMenu component - Wire createGroup through FlowModuleSchemaMap → FlowGraphV2 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: reject groups spanning parallel branches and surface ill-formed group errors Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: regenerate auto-generated files after main merge Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: ensure modules appears before groups in YAML export Svelte 5's $state proxy registers groups as a tracked property before it's explicitly set, causing it to appear before modules in Object.keys iteration. Reorder the value object at export time for readable YAML. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: address second round of PR review feedback - Add comment explaining duplicateMultiple bypasses structure tree - Add warning log for inverted ranges in computeGroupModuleIds - Use NODE.width constant in CollapsedGroupNode instead of hardcoded 275px - Simplify redundant condition in getGroupsEmptiedBy Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: remove stored group ID, derive ephemeral key from start_id:end_id Groups no longer store an `id` field. Instead, a `groupKey(g)` helper derives an ephemeral key from `${start_id}:${end_id}` at read time. This simplifies the schema while preserving all runtime functionality. When boundaries shift (module deletion), runtime state (collapse, note heights) is remapped to the new key via GroupDisplayState.remapGroupKey. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add note button, save/cancel hints, and rename collapsed_by_default to autocollapse Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: propagate selection from collapsed group badges to external listeners Pass eventHandlers to GroupModuleIcons so clicking a module badge calls both selectionManager.selectId (visual highlight) and eventHandlers.select (side panel propagation via onSelect). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: regenerate auto-generated files after main merge Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: hide In/Out popovers and actions during click-to-move Replace isDragging with isMoving derived that covers both drag-move and click-move states, disabling popovers, delete button, and test run button during any move operation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
207 lines
6.6 KiB
TypeScript
207 lines
6.6 KiB
TypeScript
import { topologicalSort } from './graphBuilder.svelte'
|
|
|
|
/** Node IDs synthesized by graphBuilder that are not real FlowModules */
|
|
export const VIRTUAL_NODE_IDS = new Set(['Input', 'Result', 'Trigger'])
|
|
|
|
type FlowNode = { id: string; parentIds?: string[] }
|
|
|
|
/**
|
|
* Compute the set of module IDs that belong to a group defined by start_id and end_id.
|
|
* Uses the flattened module list (from getAllModules) and slices between start and end.
|
|
* Used for collapsed group icons, step count, and moduleToCollapsedGroup mapping.
|
|
*/
|
|
export function computeGroupModuleIds(
|
|
startId: string,
|
|
endId: string,
|
|
allModules: { id: string }[]
|
|
): string[] {
|
|
if (startId === endId) {
|
|
return allModules.some((m) => m.id === startId) ? [startId] : []
|
|
}
|
|
|
|
const startIdx = allModules.findIndex((m) => m.id === startId)
|
|
const endIdx = allModules.findIndex((m) => m.id === endId)
|
|
|
|
if (startIdx === -1 || endIdx === -1 || startIdx > endIdx) {
|
|
if (startIdx > endIdx) {
|
|
console.warn(
|
|
`computeGroupModuleIds: inverted range for group ${startId}→${endId} (${startIdx} > ${endIdx})`
|
|
)
|
|
}
|
|
return []
|
|
}
|
|
|
|
return allModules.slice(startIdx, endIdx + 1).map((m) => m.id)
|
|
}
|
|
|
|
/**
|
|
* Check whether a set of selected node IDs can form a valid group.
|
|
* Normalizes marker IDs (branch/forloop) to parent module IDs,
|
|
* then uses topologicalSort to derive start and end boundaries.
|
|
*/
|
|
export function canFormValidGroup(
|
|
selectedIds: string[],
|
|
flowNodes: FlowNode[],
|
|
excludeIds?: Set<string>
|
|
): { valid: true; startId: string; endId: string } | { valid: false } {
|
|
if (selectedIds.length === 0) return { valid: false }
|
|
|
|
// Normalize marker IDs to parent module IDs.
|
|
// -start (forloop head) → parent ID. -end/-branch-* → skip if parent covered, else reject.
|
|
const rawSet = new Set(selectedIds)
|
|
const normalizedIds: string[] = []
|
|
|
|
for (const id of selectedIds) {
|
|
const parentId = id.replace(/-(end|start|branch-.*)$/, '')
|
|
if (parentId === id) {
|
|
normalizedIds.push(id)
|
|
continue
|
|
}
|
|
if (id.endsWith('-start')) {
|
|
normalizedIds.push(parentId)
|
|
continue
|
|
}
|
|
// -end or -branch-*: parent must be covered (directly or via -start)
|
|
if (!rawSet.has(parentId) && !rawSet.has(`${parentId}-start`)) {
|
|
return { valid: false }
|
|
}
|
|
}
|
|
|
|
if (normalizedIds.length === 0) return { valid: false }
|
|
const normalizedSet = new Set(normalizedIds)
|
|
|
|
// Topo sort full graph, filter to normalized selection.
|
|
// Include raw matches plus all markers (-start, -end, -branch-*) whose parent is selected.
|
|
const sorted = topologicalSort(flowNodes)
|
|
const selectedSorted = sorted.filter((n) => {
|
|
if (normalizedSet.has(n.id)) return true
|
|
const parentId = n.id.replace(/-(end|start|branch-.*)$/, '')
|
|
return parentId !== n.id && normalizedSet.has(parentId)
|
|
})
|
|
|
|
if (selectedSorted.length === 0) return { valid: false }
|
|
|
|
// Reject virtual or excluded nodes
|
|
if (selectedSorted.some((n) => VIRTUAL_NODE_IDS.has(n.id) || excludeIds?.has(n.id))) {
|
|
return { valid: false }
|
|
}
|
|
|
|
// Topo order is bottom-first: first = bottom (end), last = top (start).
|
|
// Use raw IDs for BFS traversal, normalize for the returned group boundaries.
|
|
const rawStartId = selectedSorted[selectedSorted.length - 1].id
|
|
const rawEndId = selectedSorted[0].id
|
|
const startId = rawStartId.replace(/-(end|start|branch-.*)$/, '')
|
|
const endId = rawEndId.replace(/-(end|start|branch-.*)$/, '')
|
|
|
|
// Verify all selected nodes lie between start and end in the DAG.
|
|
// BFS backward from rawEndId to rawStartId to collect reachable nodes.
|
|
// Normalize collected IDs so container markers map to their parent module.
|
|
const between = new Set<string>()
|
|
const queue = [rawEndId]
|
|
const visited = new Set<string>()
|
|
const parentMap = new Map(flowNodes.map((n) => [n.id, n.parentIds ?? []]))
|
|
while (queue.length > 0) {
|
|
const cur = queue.shift()!
|
|
if (visited.has(cur)) continue
|
|
visited.add(cur)
|
|
const normalized = cur.replace(/-(end|start|branch-.*)$/, '')
|
|
between.add(cur)
|
|
between.add(normalized)
|
|
if (cur === rawStartId) continue
|
|
for (const p of parentMap.get(cur) ?? []) {
|
|
queue.push(p)
|
|
}
|
|
}
|
|
if (!normalizedIds.every((id) => between.has(id))) {
|
|
return { valid: false }
|
|
}
|
|
|
|
return { valid: true, startId, endId }
|
|
}
|
|
|
|
/**
|
|
* Legacy utility: complete a group and split it into connected components.
|
|
* Still used by NoteEditor for FlowNote group notes (contained_node_ids).
|
|
*/
|
|
export function completeAndSplitGroup(groupNodes: string[], flowNodes: FlowNode[]): string[][] {
|
|
if (groupNodes.length <= 1) {
|
|
return groupNodes.length === 1 ? [groupNodes] : []
|
|
}
|
|
|
|
// Build parent map for upward traversal only
|
|
const parents = new Map<string, string[]>()
|
|
for (const node of flowNodes) {
|
|
parents.set(node.id, node.parentIds || [])
|
|
}
|
|
|
|
const groupSet = new Set(groupNodes)
|
|
const assignedComponent = new Map<string, number>()
|
|
const components: Array<Set<string>> = []
|
|
|
|
const mergeComponents = (fromIdx: number, toIdx: number): void => {
|
|
if (fromIdx === toIdx) return
|
|
const target = components[toIdx]
|
|
const source = components[fromIdx]
|
|
|
|
source.forEach((node) => target.add(node))
|
|
source.clear()
|
|
|
|
for (const [nodeId, idx] of assignedComponent.entries()) {
|
|
if (idx === fromIdx) {
|
|
assignedComponent.set(nodeId, toIdx)
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const startNode of groupNodes) {
|
|
if (assignedComponent.has(startNode)) continue
|
|
|
|
const componentIdx = components.length
|
|
components.push(new Set([startNode]))
|
|
assignedComponent.set(startNode, componentIdx)
|
|
|
|
const stack: { nodeId: string; path: string[]; seen: Set<string> }[] = [
|
|
{ nodeId: startNode, path: [startNode], seen: new Set([startNode]) }
|
|
]
|
|
|
|
while (stack.length > 0) {
|
|
const { nodeId, path, seen } = stack.pop()!
|
|
const parentIds = parents.get(nodeId) || []
|
|
|
|
for (const parentId of parentIds) {
|
|
if (seen.has(parentId)) continue
|
|
|
|
const newPath = [...path, parentId]
|
|
const newSeen = new Set(seen)
|
|
newSeen.add(parentId)
|
|
|
|
if (groupSet.has(parentId)) {
|
|
const existingIdx = assignedComponent.get(parentId)
|
|
if (existingIdx === undefined) {
|
|
assignedComponent.set(parentId, componentIdx)
|
|
components[componentIdx].add(parentId)
|
|
stack.push({ nodeId: parentId, path: [parentId], seen: new Set([parentId]) })
|
|
} else if (existingIdx !== componentIdx) {
|
|
mergeComponents(existingIdx, componentIdx)
|
|
}
|
|
|
|
for (const node of newPath) {
|
|
components[componentIdx].add(node)
|
|
}
|
|
} else {
|
|
stack.push({ nodeId: parentId, path: newPath, seen: newSeen })
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return components
|
|
.filter((component) => component.size > 0)
|
|
.map((component) =>
|
|
Array.from(component)
|
|
.filter((nodeId) => !nodeId.startsWith('subflow:'))
|
|
.sort()
|
|
)
|
|
.filter((component) => component.length > 0)
|
|
}
|