mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 00:03:00 +00:00
feat: show and edit groups in the simplified view
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
13d9ec652e
commit
da1c1fe690
@@ -1338,6 +1338,7 @@
|
||||
{selectionManager}
|
||||
selectionOverlay={multiSelectEnabled ? selectionBox : undefined}
|
||||
selectionKey={editMode ? modifierKey : undefined}
|
||||
{showNotes}
|
||||
structure={'structureTree' in graph ? graph.structureTree : undefined}
|
||||
{editMode}
|
||||
{insertable}
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
import { getSimplifiedEditContext, useStepMenu } from './simplifiedContext'
|
||||
import { getFlowRunStatusContext } from '../flowRunStatus.svelte'
|
||||
import { SIMPLIFIED, type SimplifiedBoxData } from './simplifiedLayout'
|
||||
import GroupHeaderBlock from '../GroupHeaderBlock.svelte'
|
||||
import { NOTE_COLORS, NoteColor } from '../noteColors'
|
||||
|
||||
let { id, data, selected }: { id: string; data: SimplifiedBoxData; selected?: boolean } = $props()
|
||||
|
||||
@@ -29,54 +31,84 @@
|
||||
const stepMenu = useStepMenu()
|
||||
let menuItems = $derived(data.kind === 'group' ? [] : stepMenu(id, header.module))
|
||||
let canAddBranch = $derived(data.kind === 'branch' && !!edit?.editMode && !!edit.eventHandlers)
|
||||
|
||||
let groupColors = $derived(
|
||||
NOTE_COLORS[(data.group?.value.color as NoteColor) ?? NoteColor.BLUE] ??
|
||||
NOTE_COLORS[NoteColor.BLUE]
|
||||
)
|
||||
</script>
|
||||
|
||||
<!-- A bracket over the group rather than a box around it: only the label takes clicks, so the
|
||||
group's empty area stays part of the canvas. -->
|
||||
<div class="relative pointer-events-none" style="width: {data.width}px; height: {data.height}px;">
|
||||
{#if data.kind === 'group' && data.group}
|
||||
<!-- A user-made group keeps the full graph's outlined, tinted box and its editable header -->
|
||||
<div
|
||||
class={twMerge(
|
||||
'absolute inset-x-0 h-2.5 rounded-t-lg border-t border-x',
|
||||
selected
|
||||
? 'border-border-selected'
|
||||
: (statusBracket ?? 'border-gray-300 dark:border-gray-600')
|
||||
'relative rounded-lg outline outline-1 -outline-offset-1 pointer-events-none',
|
||||
groupColors.outline,
|
||||
groupColors.backgroundLight
|
||||
)}
|
||||
style="top: {headerTop + SIMPLIFIED.loopHeader + 1}px;"
|
||||
></div>
|
||||
<StepContextMenu items={menuItems}>
|
||||
<div
|
||||
class="absolute left-1 max-w-[calc(100%-8px)] inline-flex items-center gap-1.5 px-1.5 text-2xs text-secondary min-w-0 cursor-pointer pointer-events-auto"
|
||||
style="top: {headerTop}px; height: {SIMPLIFIED.loopHeader}px;"
|
||||
title={[header.title, header.detail].filter(Boolean).join(' · ')}
|
||||
>
|
||||
{#if header.module}
|
||||
<span class="shrink-0"><FlowModuleIcon module={header.module} size={12} /></span>
|
||||
{/if}
|
||||
<span
|
||||
class={twMerge(
|
||||
'font-medium truncate shrink-0 max-w-[75%]',
|
||||
selected ? 'text-accent' : 'text-emphasis'
|
||||
)}
|
||||
>
|
||||
{header.title}
|
||||
</span>
|
||||
<span class="font-mono text-primary shrink-0">{header.stepId}</span>
|
||||
{#if header.detail}
|
||||
<span class="font-mono text-hint truncate min-w-0">{header.detail}</span>
|
||||
{/if}
|
||||
{#if canAddBranch}
|
||||
<button
|
||||
type="button"
|
||||
title="Add branch"
|
||||
class="nodrag shrink-0 rounded p-0.5 text-secondary hover:text-primary hover:bg-surface-hover"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation()
|
||||
edit?.eventHandlers?.newBranch(id)
|
||||
}}
|
||||
>
|
||||
<GitBranchPlus size={12} />
|
||||
</button>
|
||||
{/if}
|
||||
style="width: {data.width}px; height: {data.height}px;"
|
||||
>
|
||||
<div class="absolute inset-x-0 top-0 pointer-events-auto">
|
||||
<GroupHeaderBlock
|
||||
groupId={data.group.key}
|
||||
summary={data.group.value.summary}
|
||||
note={data.group.value.note}
|
||||
color={data.group.value.color}
|
||||
collapsed={false}
|
||||
autocollapse={data.group.value.autocollapse ?? false}
|
||||
editMode={!!edit?.editMode}
|
||||
showNotes={edit?.showNotes ?? true}
|
||||
/>
|
||||
</div>
|
||||
</StepContextMenu>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- A bracket over the group rather than a box around it: only the label takes clicks, so the
|
||||
group's empty area stays part of the canvas. -->
|
||||
<div class="relative pointer-events-none" style="width: {data.width}px; height: {data.height}px;">
|
||||
<div
|
||||
class={twMerge(
|
||||
'absolute inset-x-0 h-2.5 rounded-t-lg border-t border-x',
|
||||
selected
|
||||
? 'border-border-selected'
|
||||
: (statusBracket ?? 'border-gray-300 dark:border-gray-600')
|
||||
)}
|
||||
style="top: {headerTop + SIMPLIFIED.loopHeader + 1}px;"
|
||||
></div>
|
||||
<StepContextMenu items={menuItems}>
|
||||
<div
|
||||
class="absolute left-1 max-w-[calc(100%-8px)] inline-flex items-center gap-1.5 px-1.5 text-2xs text-secondary min-w-0 cursor-pointer pointer-events-auto"
|
||||
style="top: {headerTop}px; height: {SIMPLIFIED.loopHeader}px;"
|
||||
title={[header.title, header.detail].filter(Boolean).join(' · ')}
|
||||
>
|
||||
{#if header.module}
|
||||
<span class="shrink-0"><FlowModuleIcon module={header.module} size={12} /></span>
|
||||
{/if}
|
||||
<span
|
||||
class={twMerge(
|
||||
'font-medium truncate shrink-0 max-w-[75%]',
|
||||
selected ? 'text-accent' : 'text-emphasis'
|
||||
)}
|
||||
>
|
||||
{header.title}
|
||||
</span>
|
||||
<span class="font-mono text-primary shrink-0">{header.stepId}</span>
|
||||
{#if header.detail}
|
||||
<span class="font-mono text-hint truncate min-w-0">{header.detail}</span>
|
||||
{/if}
|
||||
{#if canAddBranch}
|
||||
<button
|
||||
type="button"
|
||||
title="Add branch"
|
||||
class="nodrag shrink-0 rounded p-0.5 text-secondary hover:text-primary hover:bg-surface-hover"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation()
|
||||
edit?.eventHandlers?.newBranch(id)
|
||||
}}
|
||||
>
|
||||
<GitBranchPlus size={12} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</StepContextMenu>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import type { GraphEventHandlers } from '../graphBuilder.svelte'
|
||||
import type { SelectionManager } from '../selectionUtils.svelte'
|
||||
import SelectionTool from '../SelectionTool.svelte'
|
||||
import { getGraphContext } from '../graphContext'
|
||||
import SimplifiedNode from './SimplifiedNode.svelte'
|
||||
import SimplifiedBox from './SimplifiedBox.svelte'
|
||||
import SimplifiedEdge from './SimplifiedEdge.svelte'
|
||||
@@ -46,6 +47,8 @@
|
||||
selectionOverlay?: Snippet<[Node[]]>
|
||||
/** Held while dragging on the canvas to select a rectangle of steps */
|
||||
selectionKey?: string
|
||||
/** Whether group notes are shown */
|
||||
showNotes?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -64,7 +67,8 @@
|
||||
disableAi = false,
|
||||
selectionManager,
|
||||
selectionOverlay,
|
||||
selectionKey
|
||||
selectionKey,
|
||||
showNotes = true
|
||||
}: Props = $props()
|
||||
|
||||
setSimplifiedEditContext({
|
||||
@@ -76,16 +80,37 @@
|
||||
},
|
||||
get disableAi() {
|
||||
return disableAi
|
||||
},
|
||||
get showNotes() {
|
||||
return showNotes
|
||||
}
|
||||
})
|
||||
|
||||
// Collapse and note heights live on the full graph's display state, so both views share them
|
||||
const groupDisplayState = getGraphContext()?.groupDisplayState
|
||||
let collapsedGroups = $derived(
|
||||
new Set(
|
||||
(structure ?? [])
|
||||
.flatMap(function keys(n): string[] {
|
||||
return [
|
||||
...(n.kind === 'group' ? [n.id] : []),
|
||||
...n.branches.flatMap((b) => b.children.flatMap(keys))
|
||||
]
|
||||
})
|
||||
.filter((key) => groupDisplayState?.isRuntimeCollapsed(key))
|
||||
)
|
||||
)
|
||||
let groupNoteHeights = $derived(showNotes ? (groupDisplayState?.getNoteHeights() ?? {}) : {})
|
||||
|
||||
let layout = $derived(
|
||||
layoutSimplifiedFlow({
|
||||
modules: modules ?? [],
|
||||
structure,
|
||||
failureModule,
|
||||
preprocessorModule,
|
||||
editable: editMode && insertable && !!eventHandlers
|
||||
editable: editMode && insertable && !!eventHandlers,
|
||||
collapsedGroups,
|
||||
groupNoteHeights
|
||||
})
|
||||
)
|
||||
// The full graph selects a branch lane as `<module id>-branch-<n|default>`; here the lane
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
import { SIMPLIFIED, type SimplifiedNodeData } from './simplifiedLayout'
|
||||
import { getSimplifiedEditContext, useStepMenu } from './simplifiedContext'
|
||||
import SimplifiedSlot from './SimplifiedSlot.svelte'
|
||||
import GroupHeaderBlock from '../GroupHeaderBlock.svelte'
|
||||
import GroupModuleIcons from '../GroupModuleIcons.svelte'
|
||||
import { NOTE_COLORS, NoteColor } from '../noteColors'
|
||||
|
||||
let { id, data, selected }: { id: string; data: SimplifiedNodeData; selected?: boolean } =
|
||||
$props()
|
||||
@@ -87,6 +90,30 @@
|
||||
{/if}
|
||||
</div>
|
||||
</StepContextMenu>
|
||||
{:else if data.kind === 'collapsedGroup' && data.group}
|
||||
{@const groupColors =
|
||||
NOTE_COLORS[(data.group.value.color as NoteColor) ?? NoteColor.BLUE] ??
|
||||
NOTE_COLORS[NoteColor.BLUE]}
|
||||
<div
|
||||
class="rounded-lg outline outline-1 -outline-offset-1 {groupColors.outline} {groupColors.backgroundLight}"
|
||||
style="width: {data.width}px; height: {data.height}px;"
|
||||
>
|
||||
<GroupHeaderBlock
|
||||
groupId={data.group.key}
|
||||
summary={data.group.value.summary}
|
||||
note={data.group.value.note}
|
||||
color={data.group.value.color}
|
||||
collapsed={true}
|
||||
autocollapse={data.group.value.autocollapse ?? false}
|
||||
editMode={!!edit?.editMode}
|
||||
showNotes={edit?.showNotes ?? true}
|
||||
/>
|
||||
{#if data.group.modules.length}
|
||||
<div class="flex items-center justify-center w-full gap-1.5 px-2 h-[34px] overflow-hidden">
|
||||
<GroupModuleIcons modules={data.group.modules} eventHandlers={edit?.eventHandlers} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if data.kind === 'junction'}
|
||||
<div
|
||||
class="rounded-full bg-gray-400 dark:bg-gray-500"
|
||||
|
||||
@@ -14,6 +14,8 @@ export type SimplifiedEditContext = {
|
||||
readonly eventHandlers: GraphEventHandlers | undefined
|
||||
readonly editMode: boolean
|
||||
readonly disableAi: boolean
|
||||
/** Whether group notes are shown, as the full graph's Notes toggle sets it */
|
||||
readonly showNotes: boolean
|
||||
}
|
||||
|
||||
const KEY = 'SimplifiedFlowGraphEdit'
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { FlowModule } from '$lib/gen'
|
||||
import { layoutSimplifiedFlow, SIMPLIFIED, type SimplifiedEdgeData } from './simplifiedLayout'
|
||||
import {
|
||||
layoutSimplifiedFlow,
|
||||
SIMPLIFIED,
|
||||
type SimplifiedEdgeData,
|
||||
type SimplifiedNodeData
|
||||
} from './simplifiedLayout'
|
||||
|
||||
const measure = (text: string) => text.length * 7
|
||||
|
||||
@@ -107,4 +112,39 @@ describe('layoutSimplifiedFlow', () => {
|
||||
expect(junction.position.x).toBeGreaterThan(byId.a.position.x + byId.a.width!)
|
||||
expect(junction.position.x + junction.width!).toBeLessThan(byId.c.position.x)
|
||||
})
|
||||
|
||||
it('gives every "+" the insert payload the editor resolves, branch-one default being branch 0', () => {
|
||||
const modules: FlowModule[] = [
|
||||
step('a', 'A'),
|
||||
{
|
||||
id: 'b',
|
||||
value: {
|
||||
type: 'branchone',
|
||||
branches: [{ expr: 'x', modules: [step('c', 'C')] }],
|
||||
default: []
|
||||
}
|
||||
}
|
||||
]
|
||||
const { nodes } = layoutSimplifiedFlow({ modules, editable: true }, measure)
|
||||
const inserts = nodes.flatMap((n) => {
|
||||
const { insert } = n.data as SimplifiedNodeData
|
||||
return insert ? [{ id: n.id, ...insert }] : []
|
||||
})
|
||||
const at = (index: number, rootId?: string, branch?: number) =>
|
||||
inserts.find(
|
||||
(i) => i.index === index && i.branch?.rootId === rootId && i.branch?.branch === branch
|
||||
)
|
||||
|
||||
// Top level: between Input and a (the only place a trigger may go), and before Result
|
||||
expect(at(0)).toMatchObject({ sourceId: 'Input', targetId: 'a', allowTrigger: true })
|
||||
expect(at(2)).toMatchObject({ sourceId: 'b', targetId: 'Result', allowTrigger: false })
|
||||
// Inside the condition's lane, which the step tree numbers after the default
|
||||
expect(at(0, 'b', 1)).toMatchObject({ targetId: 'c', ancestors: ['b'] })
|
||||
expect(at(1, 'b', 1)).toMatchObject({ sourceId: 'c' })
|
||||
// The empty default lane's dot is its own "+"
|
||||
expect(inserts.find((i) => i.id === 'b:empty-0')).toMatchObject({
|
||||
index: 0,
|
||||
branch: { rootId: 'b', branch: 0 }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { FlowModule } from '$lib/gen'
|
||||
import { prettyLanguage } from '$lib/common'
|
||||
import { getAllModules } from '$lib/components/flows/flowExplorer'
|
||||
import { buildStructureTree, type FlowStructureNode } from '../flowStructure'
|
||||
import type { FlowGroup } from '../groupEditor.svelte'
|
||||
import { GROUP_HEADER_HEIGHT, type FlowGroup } from '../groupEditor.svelte'
|
||||
|
||||
/**
|
||||
* Left-to-right layout for the simplified flow view. Each layer is a column whose width is its
|
||||
@@ -28,6 +28,7 @@ export const SIMPLIFIED = {
|
||||
trunkGap: 88,
|
||||
/** Column gap holding a junction, with a trunk on each side of it */
|
||||
junctionGap: 120,
|
||||
collapsedGroupWidth: 240,
|
||||
/** Extra box side padding and column gap in the editor, where "+" buttons sit there */
|
||||
editRing: 12,
|
||||
editGap: 32
|
||||
@@ -46,6 +47,8 @@ export type SimplifiedNodeKind =
|
||||
| 'caption'
|
||||
/** A "+" between two steps, only laid out when the graph is editable */
|
||||
| 'slot'
|
||||
/** A collapsed group, drawn as one card in place of its steps */
|
||||
| 'collapsedGroup'
|
||||
|
||||
export type SimplifiedNodeData = {
|
||||
kind: SimplifiedNodeKind
|
||||
@@ -61,6 +64,8 @@ export type SimplifiedNodeData = {
|
||||
insert?: InsertTarget
|
||||
/** On a branch caption, when that branch can be deleted */
|
||||
deleteBranch?: { id: string; index: number }
|
||||
/** On a collapsed group: the group, keyed by `groupKey`, and the steps it hides */
|
||||
group?: { key: string; value: FlowGroup; modules: FlowModule[] }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,8 +91,10 @@ export type SimplifiedBoxData = {
|
||||
width: number
|
||||
height: number
|
||||
header: SimplifiedNodeData
|
||||
/** Only for groups */
|
||||
group?: FlowGroup
|
||||
/** Height reserved above the contents for the header (and a group's note) */
|
||||
headerHeight: number
|
||||
/** Only for groups, keyed by `groupKey` */
|
||||
group?: { key: string; value: FlowGroup }
|
||||
}
|
||||
|
||||
export type SimplifiedEdgeData = {
|
||||
@@ -272,7 +279,8 @@ type Box = {
|
||||
id: string
|
||||
kind: SimplifiedBoxKind
|
||||
header: SimplifiedNodeData
|
||||
group?: FlowGroup
|
||||
headerHeight: number
|
||||
group?: { key: string; value: FlowGroup }
|
||||
startLayer: number
|
||||
endLayer: number
|
||||
y: number
|
||||
@@ -307,7 +315,13 @@ type Block = {
|
||||
endLayer: number
|
||||
}
|
||||
|
||||
type Ctx = { measure: TextMeasurer; modules: Map<string, FlowModule>; editable: boolean }
|
||||
type Ctx = {
|
||||
measure: TextMeasurer
|
||||
modules: Map<string, FlowModule>
|
||||
editable: boolean
|
||||
collapsedGroups: Set<string>
|
||||
groupNoteHeights: Record<string, number>
|
||||
}
|
||||
|
||||
/** Where a sequence of steps lives in the step tree, as the insert payload names it */
|
||||
type Container = { rootRef?: string; branch: number; ancestors: string[] }
|
||||
@@ -524,10 +538,11 @@ function layoutLane(
|
||||
function boxed(
|
||||
box: Pick<Box, 'id' | 'kind' | 'header' | 'group'>,
|
||||
layer: number,
|
||||
inner: Block
|
||||
inner: Block,
|
||||
headerHeight: number = SIMPLIFIED.loopHeader
|
||||
): Block {
|
||||
const pad = SIMPLIFIED.loopPad
|
||||
const top = pad + SIMPLIFIED.loopHeader
|
||||
const top = pad + headerHeight
|
||||
const height = top + inner.height + pad
|
||||
const b = shift(inner, top)
|
||||
return {
|
||||
@@ -536,6 +551,7 @@ function boxed(
|
||||
boxes: [
|
||||
{
|
||||
...box,
|
||||
headerHeight,
|
||||
startLayer: layer,
|
||||
endLayer: inner.endLayer,
|
||||
y: 0,
|
||||
@@ -583,9 +599,23 @@ function groupHeader(group: FlowGroup | undefined, stepCount: number): Simplifie
|
||||
}
|
||||
|
||||
function layoutNode(node: FlowStructureNode, layer: number, ancestors: string[], ctx: Ctx): Block {
|
||||
if (node.kind === 'group') {
|
||||
if (node.kind === 'group' && node.group) {
|
||||
const group = { key: node.id, value: node.group }
|
||||
const noteHeight = ctx.groupNoteHeights[node.id] ?? 0
|
||||
if (ctx.collapsedGroups.has(node.id)) {
|
||||
const modules = (node.moduleIds ?? []).flatMap((id) => ctx.modules.get(id) ?? [])
|
||||
const data: SimplifiedNodeData = {
|
||||
kind: 'collapsedGroup',
|
||||
title: node.group.summary || 'Group',
|
||||
group: { ...group, modules },
|
||||
width: SIMPLIFIED.collapsedGroupWidth,
|
||||
// Header, then a row of the hidden steps' icons, as the full graph's collapsed card
|
||||
height: GROUP_HEADER_HEIGHT + 34 + 8 + noteHeight
|
||||
}
|
||||
return leaf(`collapsed-group:${node.id}`, data, layer)
|
||||
}
|
||||
const rootRef = ref(node)
|
||||
const container = { rootRef, branch: 0, ancestors }
|
||||
const container = { rootRef, branch: 0, ancestors: [...ancestors, rootRef] }
|
||||
const lane = layoutLane(
|
||||
node.branches[0]?.children ?? [],
|
||||
layer,
|
||||
@@ -595,7 +625,8 @@ function layoutNode(node: FlowStructureNode, layer: number, ancestors: string[],
|
||||
ctx
|
||||
)
|
||||
const header = groupHeader(node.group, node.moduleIds?.length ?? 0)
|
||||
return boxed({ id: rootRef, kind: 'group', header, group: node.group }, layer, lane)
|
||||
const headerHeight = GROUP_HEADER_HEIGHT + 6 + noteHeight
|
||||
return boxed({ id: rootRef, kind: 'group', header, group }, layer, lane, headerHeight)
|
||||
}
|
||||
|
||||
const mod = ctx.modules.get(node.id)
|
||||
@@ -684,13 +715,19 @@ export function layoutSimplifiedFlow(
|
||||
preprocessorModule?: FlowModule
|
||||
/** Lays out the "+" slots, and a lane for empty loop bodies to insert into */
|
||||
editable?: boolean
|
||||
/** Keys of the groups currently collapsed, each drawn as one card */
|
||||
collapsedGroups?: Set<string>
|
||||
/** Rendered height of each group's note, reserved under its header */
|
||||
groupNoteHeights?: Record<string, number>
|
||||
},
|
||||
measure: TextMeasurer = measureTextWidth
|
||||
): SimplifiedLayout {
|
||||
const ctx: Ctx = {
|
||||
measure,
|
||||
modules: new Map(getAllModules(opts.modules ?? []).map((m) => [m.id, m])),
|
||||
editable: opts.editable ?? false
|
||||
editable: opts.editable ?? false,
|
||||
collapsedGroups: opts.collapsedGroups ?? new Set(),
|
||||
groupNoteHeights: opts.groupNoteHeights ?? {}
|
||||
}
|
||||
const structure = opts.structure ?? buildStructureTree(opts.modules ?? [], [])
|
||||
|
||||
@@ -828,6 +865,7 @@ export function layoutSimplifiedFlow(
|
||||
boxBounds.set(b.id, { left, right })
|
||||
const data: SimplifiedBoxData = {
|
||||
kind: b.kind,
|
||||
headerHeight: b.headerHeight,
|
||||
width: right - left,
|
||||
height: b.height,
|
||||
header: b.header,
|
||||
|
||||
Reference in New Issue
Block a user