Files
windmill/frontend/src/lib/components/flows/map/FlowModuleSchemaItem.svelte
T
GuilhemandClaude Opus 4.6 81eb446eee feat: flow group nodes with collapsible groups (#8075)
* 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>
2026-03-24 16:47:33 +00:00

656 lines
19 KiB
Svelte

<script lang="ts">
import { preventDefault, stopPropagation } from 'svelte/legacy'
import Popover from '$lib/components/Popover.svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import { classNames, type Item, type StateStore } from '$lib/utils'
import {
Bed,
Database,
Gauge,
EllipsisVertical,
PhoneIncoming,
Repeat,
Square,
SkipForward,
Pin,
X,
Play,
Loader2,
TriangleAlert,
Timer,
Maximize2
} from 'lucide-svelte'
import { createEventDispatcher, getContext, untrack } from 'svelte'
import { fade } from 'svelte/transition'
import type { FlowEditorContext } from '../types'
import { twMerge } from 'tailwind-merge'
import IdEditorInput from '$lib/components/IdEditorInput.svelte'
import { dfs } from '../dfs'
import { dfs as dfsPreviousResults } from '../previousResults'
import { Drawer } from '$lib/components/common'
import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte'
import { getDependeeAndDependentComponents } from '../flowExplorer'
import { replaceId } from '../flowStore.svelte'
import FlowModuleSchemaItemViewer from './FlowModuleSchemaItemViewer.svelte'
import type { PropPickerContext } from '$lib/components/prop_picker'
import OutputPicker from '$lib/components/flows/propPicker/OutputPicker.svelte'
import OutputPickerInner from '$lib/components/flows/propPicker/OutputPickerInner.svelte'
import type { FlowState } from '$lib/components/flows/flowState'
import { Button } from '$lib/components/common'
import ModuleTest from '$lib/components/ModuleTest.svelte'
import { getStepHistoryLoaderContext } from '$lib/components/stepHistoryLoader.svelte'
import type { Job } from '$lib/gen'
import {
getNodeColorClasses,
aiActionToNodeState,
type FlowNodeState
} from '$lib/components/graph'
import type { ModuleActionInfo } from '$lib/components/flows/flowDiff'
import DiffActionBar from './DiffActionBar.svelte'
import { getGraphContext } from '$lib/components/graph/graphContext'
import MoveHandleButton from '$lib/components/graph/MoveHandleButton.svelte'
interface Props {
selected?: boolean
deletable?: boolean
moduleAction: ModuleActionInfo | undefined
retry?: boolean
cache?: boolean
earlyStop?: boolean
skip?: boolean
suspend?: boolean
sleep?: boolean
mock?:
| {
enabled?: boolean
return_value?: unknown
}
| undefined
bold?: boolean
id?: string | undefined
label: string
path?: string
nodeState?: FlowNodeState
concurrency?: boolean
// TODO: Implement for this one. See how concurrency is implemented.
debouncing?: boolean
retries?: number | undefined
warningMessage?: string | undefined
isTrigger?: boolean
editMode?: boolean
alwaysShowOutputPicker?: boolean
loopStatus?: { type: 'inside' | 'self'; flow: 'forloopflow' | 'whileloopflow' } | undefined
icon?: import('svelte').Snippet
onTestUpTo?: ((id: string) => void) | undefined
inputTransform?: Record<string, any> | undefined
onUpdateMock?: (mock: { enabled: boolean; return_value?: unknown }) => void
onEditInput?: (moduleId: string, key: string) => void
flowJob?: Job | undefined
isOwner?: boolean
enableTestRun?: boolean
maximizeSubflow?: () => void
menuItems?: Item[]
}
let {
selected = false,
deletable = false,
moduleAction = undefined,
retry = false,
cache = false,
earlyStop = false,
skip = false,
suspend = false,
sleep = false,
mock = { enabled: false },
bold = false,
id = undefined,
label,
path = '',
nodeState,
concurrency = false,
debouncing = false,
retries = undefined,
warningMessage = undefined,
isTrigger = false,
editMode = false,
alwaysShowOutputPicker = false,
loopStatus = undefined,
icon,
onTestUpTo,
inputTransform,
onUpdateMock,
onEditInput,
flowJob,
enableTestRun = false,
maximizeSubflow = undefined,
menuItems = undefined
}: Props = $props()
// AI action colors take priority over execution state
let effectiveState = $derived(aiActionToNodeState(moduleAction?.action) ?? nodeState)
let colorClasses = $derived(getNodeColorClasses(effectiveState, selected))
const flowEditorContext = getContext<FlowEditorContext | undefined>('FlowEditorContext')
const flowInputsStore = flowEditorContext?.flowInputsStore
const flowStore = flowEditorContext?.flowStore
const flowGraphContext = getGraphContext()
const diffManager = flowGraphContext?.diffManager
const moveManager = flowGraphContext?.moveManager
// Hide per-node action buttons when multiple nodes are selected (multi-select mode)
let isMultiSelected = $derived((flowGraphContext?.selectionManager?.selectedIds?.length ?? 0) > 1)
let pickableIds: Record<string, any> | undefined = $state(undefined)
const dispatch = createEventDispatcher()
const propPickerContext = getContext<PropPickerContext>('PropPickerContext')
const flowPropPickerConfig = propPickerContext?.flowPropPickerConfig
const pickablePropertiesFiltered = propPickerContext?.pickablePropertiesFiltered
$effect(() => {
pickableIds = $pickablePropertiesFiltered?.priorIds
})
let editId = $state(false)
let newId: string = $state(untrack(() => id) ?? '')
let moduleTest: ModuleTest | undefined = $state(undefined)
let testIsLoading = $state(false)
let hover = $state(false)
let connectingData: any | undefined = $state(undefined)
let outputPicker: OutputPicker | undefined = $state(undefined)
let testJob: any | undefined = $state(undefined)
let outputPickerBarOpen = $state(false)
let dropdownOpen = $state(false)
let flowStateStore = $derived(flowEditorContext?.flowStateStore)
let stepHistoryLoader = getStepHistoryLoaderContext()
function updateConnectingData(
id: string | undefined,
pickableIds: Record<string, any> | undefined,
flowPropPickerConfig: any | undefined,
flowStateStore: StateStore<FlowState> | undefined
) {
if (!id) return
connectingData =
flowPropPickerConfig && pickableIds && Object.keys(pickableIds).includes(id)
? pickableIds[id]
: (flowStateStore?.val?.[id]?.previewResult ?? {})
}
$effect(() => {
updateConnectingData(id, pickableIds, $flowPropPickerConfig, flowStateStore)
})
let isConnectingCandidate = $derived(
!!id && !!$flowPropPickerConfig && !!pickableIds && Object.keys(pickableIds).includes(id)
)
let isMoving = $derived(!!moveManager?.dragging || !!moveManager?.movingModuleId)
const outputPickerVisible = $derived(
editMode && (isConnectingCandidate || alwaysShowOutputPicker) && !!id && !isMoving
)
const icon_render = $derived(icon)
let testRunDropdownOpen = $state(false)
let outputPickerInner: OutputPickerInner | undefined = $state(undefined)
let historyOpen = $derived.by(() => outputPickerInner?.getHistoryOpen?.() ?? false)
</script>
{#if deletable && id && editId}
{@const flowStore = flowEditorContext?.flowStore ?? undefined}
{@const getDeps = getDependeeAndDependentComponents(
id,
flowStore?.val?.value.modules ?? [],
flowStore?.val?.value.failure_module
)}
<Drawer bind:open={editId}>
<DrawerContent title="Edit step id {id}" on:close={() => (editId = false)}>
<div>
<IdEditorInput
buttonText="Edit Id "
btnClasses="!ml-1"
label=""
initialId={id}
acceptUnderScores
reservedIds={dfs(flowStore?.val?.value.modules ?? [], (x) => x.id)}
bind:value={newId}
onSave={({ oldId, newId }) => {
dispatch('changeId', { id: oldId, newId, deps: getDeps?.dependents ?? {} })
editId = false
}}
onClose={() => {
editId = false
}}
/>
<div class="mt-8">
<h3>Step Inputs Replacements</h3>
<div class="text-2xs text-primary pt-0.5">
Replace all occurrences of `results.<span class="font-bold">{id}</span>` with{' '}
results.<span class="font-bold">{newId}</span> in the step inputs of all steps that depend
on it.
</div>
<div class="pt-8 flex flex-col gap-y-4">
{#if Object.keys(getDeps?.dependents ?? {})?.length > 0}
{#each Object.entries(getDeps?.dependents ?? {}) as dependents}
<div>
<h4>{dependents[0]}</h4>
<div>
{#each dependents?.[1] as d}
<div>
<span class="font-mono text-sm">{d}</span> &rightarrow;
<span class="font-mono text-sm">{replaceId(d, id, newId)}</span>
</div>
{/each}
</div>
</div>
{/each}
{:else}
<div class="text-2xs text-primary"> No dependents </div>
{/if}
</div>
</div>
</div>
</DrawerContent>
</Drawer>
{/if}
{#if deletable && id && flowStore && outputPickerVisible}
{@const flowStoreVal = flowStore.val}
{@const mod = flowStoreVal?.value ? dfsPreviousResults(id, flowStoreVal, false)[0] : undefined}
{#if mod && flowStateStore?.val?.[id]}
<ModuleTest
bind:this={moduleTest}
{mod}
bind:testIsLoading
bind:testJob
onJobDone={() => {
outputPickerInner?.setJobPreview?.()
}}
/>
{/if}
{/if}
<div class="relative">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class={classNames(
'w-full module flex rounded-md cursor-pointer max-w-full drop-shadow-sm',
colorClasses.bg
)}
style="width: 275px; height: 34px;"
onmouseenter={() => (hover = true)}
onmouseleave={() => (hover = false)}
onpointerdown={stopPropagation(preventDefault((e) => dispatch('pointerdown', e)))}
>
{#if id}
<DiffActionBar moduleId={id} {moduleAction} {diffManager} {flowStore} />
{/if}
<div
class={classNames('absolute z-0 rounded-md outline-offset-0', colorClasses.outline)}
style={`width: 275px; height: 34px;`}
></div>
<div
class="absolute text-sm right-2 flex flex-row gap-1 z-10 transition-all duration-100"
style={`bottom: ${outputPickerBarOpen ? '-38px' : '-12px'}`}
>
{#if retry}
<Popover notClickable>
<div
transition:fade|local={{ duration: 200 }}
class="center-center rounded border bg-surface border-gray-400 text-secondary px-1 py-0.5"
>
{#if retries}<span class="text-red-400 mr-2">{retries}</span>{/if}
<Repeat size={12} />
</div>
{#snippet text()}
Retries
{/snippet}
</Popover>
{/if}
{#if concurrency}
<Popover notClickable>
<div
transition:fade|local={{ duration: 200 }}
class="center-center rounded border bg-surface border-gray-400 text-secondary px-1 py-0.5"
>
<Gauge size={12} />
</div>
{#snippet text()}
Concurrency Limits
{/snippet}
</Popover>
{/if}
{#if debouncing}
<Popover notClickable>
<div
transition:fade|local={{ duration: 200 }}
class="center-center rounded border bg-surface border-gray-400 text-secondary px-1 py-0.5"
>
<Timer size={12} />
</div>
{#snippet text()}
Debouncing
{/snippet}
</Popover>
{/if}
{#if cache}
<Popover notClickable>
<div
transition:fade|local={{ duration: 200 }}
class="center-center rounded border bg-surface border-gray-400 text-secondary px-1 py-0.5"
>
<Database size={12} />
</div>
{#snippet text()}
Cached
{/snippet}
</Popover>
{/if}
{#if earlyStop}
<Popover notClickable>
<div
transition:fade|local={{ duration: 200 }}
class="center-center bg-surface rounded border border-gray-400 text-secondary px-1 py-0.5"
>
<Square size={12} />
</div>
{#snippet text()}
{isTrigger ? 'Stop early if there are no new events' : 'Early stop/break'}
{/snippet}
</Popover>
{/if}
{#if skip}
<Popover notClickable>
<div
transition:fade|local={{ duration: 200 }}
class="center-center bg-surface rounded border border-gray-400 text-secondary px-1 py-0.5"
>
<SkipForward size={12} />
</div>
{#snippet text()}
Skip
{/snippet}
</Popover>
{/if}
{#if suspend}
<Popover notClickable>
<div
transition:fade|local={{ duration: 200 }}
class="center-center bg-surface rounded border border-gray-400 text-secondary px-1 py-0.5"
>
<PhoneIncoming size={12} />
</div>
{#snippet text()}
Suspend
{/snippet}
</Popover>
{/if}
{#if sleep}
<Popover notClickable>
<div
transition:fade|local={{ duration: 200 }}
class="center-center bg-surface rounded border border-gray-400 text-secondary px-1 py-0.5"
>
<Bed size={12} />
</div>
{#snippet text()}
Sleep
{/snippet}
</Popover>
{/if}
{#if mock?.enabled}
<Popover notClickable>
<button
transition:fade|local={{ duration: 200 }}
class="center-center bg-surface rounded border border-gray-400 text-secondary px-1 py-0.5"
onclick={() => {
outputPicker?.toggleOpen()
}}
data-popover
>
<Pin size={12} />
</button>
{#snippet text()}
Pinned
{/snippet}
</Popover>
{/if}
</div>
<div class="flex flex-col w-full">
<FlowModuleSchemaItemViewer
{label}
{path}
{id}
{bold}
bind:editId
disableEditId={isMultiSelected}
{colorClasses}
>
{#snippet icon()}
{@render icon_render?.()}
{/snippet}
</FlowModuleSchemaItemViewer>
{#if outputPickerVisible && !isMultiSelected}
<OutputPicker
bind:this={outputPicker}
{selected}
{hover}
{isConnectingCandidate}
{historyOpen}
{inputTransform}
id={id ?? ''}
bind:bottomBarOpen={outputPickerBarOpen}
{loopStatus}
{onEditInput}
>
{#snippet children({ allowCopy, isConnecting, selectConnection })}
<OutputPickerInner
{allowCopy}
prefix={'results'}
connectingData={isConnecting ? connectingData : undefined}
{mock}
{testJob}
moduleId={id}
onSelect={selectConnection}
{onUpdateMock}
{path}
{loopStatus}
rightMargin
historyOffset={{ mainAxis: 12, crossAxis: -9 }}
clazz="p-1"
isLoading={testIsLoading ||
(id ? stepHistoryLoader?.stepStates[id]?.loadingJobs : false)}
initial={id ? stepHistoryLoader?.stepStates[id]?.initial : undefined}
bind:this={outputPickerInner}
/>
{/snippet}
</OutputPicker>
{/if}
</div>
{#if deletable && !isMoving}
{#if maximizeSubflow !== undefined}
{@render buttonMaximizeSubflow?.()}
{/if}
{#if (id && Object.values($flowInputsStore?.[id]?.flowStepWarnings || {}).length > 0) || Boolean(warningMessage)}
<Popover
style="will-change: transform;"
class={twMerge(
'absolute -translate-y-[100%] top-1 -left-1',
'flex items-center justify-center rounded-b-none rounded-md p-1 shadow-md duration-0 ',
id &&
Object.values($flowInputsStore?.[id]?.flowStepWarnings || {})?.some(
(x) => x.type === 'error'
)
? 'border-red-600 text-red-600 bg-red-100 hover:bg-red-300'
: ' text-yellow-600 bg-yellow-100 hover:bg-yellow-300'
)}
>
{#snippet text()}
<ul class="list-disc px-2">
{#if id}
{#each Object.values($flowInputsStore?.[id]?.flowStepWarnings || {}) as m}
<li>
{m.message}
</li>
{/each}
{/if}
</ul>
{/snippet}
<TriangleAlert size={12} strokeWidth={2} />
</Popover>
{/if}
{#if !isMultiSelected && id !== 'preprocessor' && moveManager && id}
<div
class="absolute -translate-y-[100%] top-2 right-5 h-7 p-1 min-w-7"
style="will-change: transform;"
>
<MoveHandleButton
{moveManager}
moduleId={id}
singleNode
visible={hover || selected || dropdownOpen}
onClickMove={() => dispatch('move')}
class="trash group-hover:block"
/>
</div>
{/if}
{#if !isMultiSelected && menuItems && menuItems.length > 0}
<div
class="absolute -translate-y-[100%] top-2 -right-2 h-7 p-1 min-w-7"
style="will-change: transform;"
>
<DropdownV2
items={menuItems}
placement="bottom-end"
bind:open={dropdownOpen}
fixedHeight={false}
usePointerDownOutside
>
{#snippet buttonReplacement()}
<button
class={twMerge(
'trash center-center p-1 text-secondary shadow-sm bg-surface duration-0 hover:bg-surface-tertiary',
hover || selected || dropdownOpen ? 'block' : '!hidden',
'shadow-md rounded-md',
'group-hover:block'
)}
onpointerdown={stopPropagation(preventDefault(() => {}))}
title="Actions"
>
<EllipsisVertical size={12} />
</button>
{/snippet}
</DropdownV2>
</div>
{/if}
{:else if maximizeSubflow !== undefined}
{@render buttonMaximizeSubflow?.()}
{/if}
</div>
{#if editMode && enableTestRun && flowJob?.type !== 'QueuedJob' && !isMoving}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute top-1/2 -translate-y-1/2 -translate-x-[100%] -left-[0] flex items-center w-fit px-1 h-9 min-w-9"
onmouseenter={() => (hover = true)}
onmouseleave={() => (hover = false)}
>
{#if !isMultiSelected && (hover || selected || testRunDropdownOpen) && outputPickerVisible}
<div class="bg-surface rounded-md" transition:fade={{ duration: 100 }}>
{#if !testIsLoading}
<Button
size="xs"
title="Run"
variant="default"
btnClasses="px-1 py-1.5 bg-surface"
on:click={() => {
outputPicker?.toggleOpen(true)
moduleTest?.loadArgsAndRunTest()
}}
dropdownItems={[
{
label: 'Test up to here',
onClick: () => {
if (id) {
onTestUpTo?.(id)
}
}
}
]}
dropdownBtnClasses="!w-3 px-0.5"
bind:dropdownOpen={testRunDropdownOpen}
>
{#if testIsLoading}
<Loader2 size={12} class="animate-spin" />
{:else}
<Play size={12} />
{/if}
</Button>
{:else}
<Button
size="xs"
color="red"
variant="contained"
btnClasses="!h-[25.5px] !w-[36px] !p-1.5 gap-0.5"
on:click={async () => {
moduleTest?.cancelJob()
}}
>
<Loader2 size={10} class="animate-spin mr-0.5" />
<X size={14} />
</Button>
{/if}
</div>
{/if}
</div>
{/if}
</div>
{#snippet buttonMaximizeSubflow()}
<div class="absolute -translate-y-[100%] top-2 right-12 h-7 p-1">
<button
title="Expand subflow"
class={twMerge(
'center-center text-secondary shadow-sm bg-surface duration-0 hover:bg-surface-tertiary p-1',
'shadow-md rounded-md',
!isMultiSelected && (hover || selected) ? 'opacity-100' : 'opacity-50'
)}
onclick={(e) => {
e.stopPropagation()
e.preventDefault()
maximizeSubflow?.()
}}
onpointerdown={(e) => {
e.stopPropagation()
e.preventDefault()
}}
>
<Maximize2 size={12} />
</button>
</div>
{/snippet}
<style>
.module:hover .trash {
display: flex !important;
}
</style>