Files
windmill/frontend/src/lib/components/flows/content/FlowLoop.svelte
T
GuilhemandClaude Opus 4.6 c0c9388415 feat: add move, delete, and duplicate to flow node context menu (#8050)
* feat: add context menu, multi-select actions, and keyboard shortcuts to flow editor

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address review feedback on context menu PR

- Revert accidental static import of @scalar/openapi-parser (keep lazy-loaded)
- Restore [data-context-menu] in portalDivs for clickOutside compatibility
- Make noteDisabled reactive ($derived) in ModuleNode
- Use platform-aware shortcut hint (⌫ on Mac, Del on Windows/Linux)
- Optimize resolveSelectedModuleIds with single-pass ancestor map

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address additional review feedback on flow context menu PR

- Use $derived.by instead of $derived for computed bounds in SelectionBoundingBox
- Remove redundant structuredClone wrappers around $state.snapshot
- Add null guard for originalModules/targetModules in move handler
- Add upper-bound guard (n < 10000) to copyId loop
- Fix fragile toggle comparison in moveManager with full array equality

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:53:01 +00:00

501 lines
16 KiB
Svelte

<script lang="ts">
import { getContext } from 'svelte'
import FlowCard from '../common/FlowCard.svelte'
import type { FlowEditorContext } from '../types'
import Toggle from '$lib/components/Toggle.svelte'
import SimpleEditor from '$lib/components/SimpleEditor.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import FlowModuleEarlyStop from './FlowModuleEarlyStop.svelte'
import FlowModuleSuspend from './FlowModuleSuspend.svelte'
// import FlowRetries from './FlowRetries.svelte'
import { Button, Drawer, Tab, TabContent } from '$lib/components/common'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import { getStepPropPicker } from '../previousResults'
import { enterpriseLicense } from '$lib/stores'
import FlowModuleSleep from './FlowModuleSleep.svelte'
import FlowModuleMock from './FlowModuleMock.svelte'
import { Play, FunctionSquare } from 'lucide-svelte'
import type { FlowModule, ForloopFlow, Job } from '$lib/gen'
import FlowLoopIterationPreview from '$lib/components/FlowLoopIterationPreview.svelte'
import FlowModuleDeleteAfterUse from './FlowModuleDeleteAfterUse.svelte'
import IteratorGen from '$lib/components/copilot/IteratorGen.svelte'
import FlowModuleSkip from './FlowModuleSkip.svelte'
import FlowPlugConnect from '$lib/components/FlowPlugConnect.svelte'
import PropPickerWrapper from '../propPicker/PropPickerWrapper.svelte'
import type { PropPickerContext } from '$lib/components/prop_picker'
import TabsV2 from '$lib/components/common/tabs/TabsV2.svelte'
import { useUiIntent } from '$lib/components/copilot/chat/flow/useUiIntent'
import { emptySchema } from '$lib/utils'
import { slide } from 'svelte/transition'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import Badge from '$lib/components/common/badge/Badge.svelte'
const { previewArgs, flowStateStore, flowStore, currentEditor } =
getContext<FlowEditorContext>('FlowEditorContext')
interface Props {
mod: FlowModule
parentModule: FlowModule | undefined
previousModule: FlowModule | undefined
noEditor: boolean
enableAi?: boolean
}
let {
mod = $bindable(),
parentModule,
previousModule,
noEditor,
enableAi = false
}: Props = $props()
let editor: SimpleEditor | undefined = $state(undefined)
let parallelismEditor: SimpleEditor | undefined = $state(undefined)
let selected: string = $state('early-stop')
let parallelismType: 'static' | 'javascript' | undefined = $state(
mod.value.type === 'forloopflow'
? mod.value.parallelism?.type === 'javascript'
? 'javascript'
: 'static'
: undefined
)
let parallelismSchema = $state(emptySchema())
parallelismSchema.properties['parallelism'] = {
type: 'number'
}
if (mod.value.type === 'forloopflow') {
const forloopValue = mod.value as ForloopFlow
if (typeof forloopValue.parallelism === 'number') {
forloopValue.parallelism = {
type: 'static',
value: forloopValue.parallelism
}
}
}
// UI Intent handling for AI tool control
useUiIntent(`forloopflow-${mod.id}`, {
openTab: (tab) => {
selected = tab
}
})
const { flowPropPickerConfig } = getContext<PropPickerContext>('PropPickerContext')
flowPropPickerConfig.set(undefined)
let stepPropPicker = $derived(
getStepPropPicker(
flowStateStore.val,
parentModule,
previousModule,
mod.id,
flowStore.val,
previewArgs.val,
false
)
)
let previewOpen = $state(false)
let jobId: string | undefined = $state(undefined)
let job: Job | undefined = $state(undefined)
let iteratorFieldFocused = $state(false)
let iteratorGen: IteratorGen | undefined = $state(undefined)
let previewIterationArgs = $derived(flowStateStore.val[mod.id]?.previewArgs ?? {})
function setExpr(code: string) {
if (mod.value.type === 'forloopflow') {
mod.value.iterator = {
type: 'javascript',
expr: code
}
}
editor?.setCode('')
editor?.insertAtCursor(code)
}
$effect(() => {
editor && (currentEditor as any).set({ type: 'iterator', editor, stepId: mod.id })
})
let suggestion: string | undefined = $state(undefined)
</script>
<Drawer bind:open={previewOpen} alwaysOpen size="75%">
<FlowLoopIterationPreview
modules={mod.value.type == 'forloopflow' ? mod.value.modules : []}
open={previewOpen}
previewArgs={previewIterationArgs}
bind:job
bind:jobId
on:close={() => {
previewOpen = false
}}
/>
</Drawer>
<FlowCard {noEditor} title="For loop">
{#snippet header()}
<div class="grow">
<div class="my-2 flex flex-row gap-2 items-center">
<div>
<Tooltip documentationLink="https://www.windmill.dev/docs/flows/flow_loops">
Add steps inside the loop and specify an iterator expression that defines the sequence
over which your subsequent steps will iterate.
</Tooltip>
</div>
<div class="grow">
<input bind:value={mod.summary} placeholder={'Summary'} />
</div>
<div class="justify-end">
<Button
on:click={() => (previewOpen = true)}
startIcon={{ icon: Play }}
variant="accent"
size="sm">Test an iteration</Button
>
</div>
</div>
</div>
{/snippet}
<Splitpanes horizontal class="h-full">
<Pane size={50} minSize={20} class="p-4">
{#if mod.value.type === 'forloopflow'}
<div class="flex flex-row gap-6 mt-2 mb-6">
<div class="flex-shrink-0">
<div class="mb-2 text-sm font-bold"
>Skip failures <Tooltip
documentationLink="https://www.windmill.dev/docs/flows/flow_loops"
>If disabled, the flow will fail as soon as one of the iteration fail. Otherwise,
the error will be collected as the result of the iteration. Regardless of this
setting, if a flow level error handler is defined, it will process the error.
(Workspace error handlers will NOT be used to process errors if enabled.)</Tooltip
></div
>
<Toggle
bind:checked={mod.value.skip_failures}
options={{
right: 'Skip failures'
}}
class="whitespace-nowrap"
/>
</div>
<div class="flex-shrink-0">
<div class="mb-2 text-sm font-bold"
>Squash
<Badge
>Beta <Tooltip documentationLink="https://www.windmill.dev/docs/flows/flow_loops">
<span class="font-semibold"
>This can result in unexpected behavior, use at your own risk for now.</span
><br />
Squashing a for loop runs all iterations on the same worker, using a single runner
per step for the entire loop. This eliminates cold starts between iterations for supported
languages (Bun, Deno, and Python).
</Tooltip>
</Badge>
</div>
<Toggle
bind:checked={mod.value.squash}
on:change={({ detail }) => {
;(mod.value as ForloopFlow).squash = detail
}}
options={{
right: 'Squash'
}}
class="whitespace-nowrap"
disabled={mod.value.parallel}
/>
</div>
<div class="flex-shrink-0">
<div class="mb-2 text-sm font-bold">Run in parallel</div>
<Toggle
bind:checked={mod.value.parallel}
on:change={({ detail }) => {
if (detail === false) {
;(mod.value as ForloopFlow).parallelism = undefined
}
}}
options={{
right: 'All iterations run in parallel'
}}
class="whitespace-nowrap"
disabled={mod.value.squash}
/>
</div>
<div class="flex-shrink-0">
<div class="mb-2 text-sm font-bold"
>Parallelism <Tooltip
>Assign a maximum number of branches run in parallel to control huge for-loops.</Tooltip
>
</div>
<div class="flex gap-2 items-center">
<input
type="number"
min="1"
class="w-20 px-2 py-1 text-sm border border-gray-200 dark:border-gray-700 rounded bg-surface"
disabled={!mod.value.parallel || parallelismType === 'javascript'}
placeholder={parallelismType === 'javascript' ? 'Expression' : ''}
bind:value={
() => {
const parallelismExpr = (mod.value as ForloopFlow).parallelism
return parallelismExpr && parallelismExpr.type === 'static'
? parallelismExpr.value
: ''
},
(value) => {
;(mod.value as ForloopFlow).parallelism = {
type: 'static',
value
}
}
}
/>
<ToggleButtonGroup
disabled={!mod.value.parallel}
bind:selected={parallelismType}
on:selected={(e) => {
const forLoopFlow = mod.value as ForloopFlow
if (e.detail == parallelismType) return
if (e.detail === 'javascript') {
if (!forLoopFlow.parallelism || forLoopFlow.parallelism.type !== 'javascript') {
;(mod.value as ForloopFlow).parallelism = {
type: 'javascript',
expr: ''
}
}
} else {
if (!forLoopFlow.parallelism || forLoopFlow.parallelism.type !== 'static') {
;(mod.value as ForloopFlow).parallelism = {
type: 'static',
value: 0
}
}
}
}}
class="h-6"
>
{#snippet children({ item })}
<ToggleButton small label="static" value="static" {item} />
<ToggleButton
small
tooltip="JavaScript expression ('flow_input' or 'results')."
value="javascript"
icon={FunctionSquare}
{item}
/>
{/snippet}
</ToggleButtonGroup>
</div>
</div>
</div>
{#if mod.value.type === 'forloopflow' && mod.value.parallel && mod.value.parallelism?.type == 'javascript'}
<div class="my-2 flex flex-row gap-2 items-center">
<div class="text-sm font-bold whitespace-nowrap">
Parallelism expression
<Tooltip>
JavaScript expression that defines the maximum number of parallel executions.
Example: flow_input.max_parallel || 3
</Tooltip>
</div>
</div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="border rounded-md overflow-auto w-full mb-2 h-full max-h-[250px]"
id="flow-editor-parallel-expression"
transition:slide={{ duration: 300 }}
>
<PropPickerWrapper
notSelectable
noPadding
flow_input={stepPropPicker.pickableProperties.flow_input}
pickableProperties={stepPropPicker.pickableProperties}
on:select={({ detail }) => {
parallelismEditor?.insertAtCursor(detail)
parallelismEditor?.focus()
}}
>
<SimpleEditor
bind:this={parallelismEditor}
autofocus
lang="javascript"
bind:code={
() => {
const parallelismExpr = (mod.value as ForloopFlow).parallelism
return parallelismExpr && parallelismExpr.type === 'javascript'
? parallelismExpr.expr
: ''
},
(expr) => {
;(mod.value as ForloopFlow).parallelism = {
type: 'javascript',
expr
}
}
}
class="h-full"
shouldBindKey={false}
extraLib={stepPropPicker.extraLib}
/>
</PropPickerWrapper>
</div>
{/if}
<div class="my-2 flex flex-row gap-2 items-center">
<div class="text-sm font-bold whitespace-nowrap">
Iterator expression
<Tooltip documentationLink="https://www.windmill.dev/docs/flows/flow_loops">
The JavaScript expression that will be evaluated to get the list of items to iterate
over. Example : ["banana", "apple", flow_input.my_fruit].
</Tooltip>
</div>
<FlowPlugConnect
connecting={$flowPropPickerConfig != undefined}
on:click={() => {
const config = {
onSelect: (code) => {
setExpr(code)
return true
},
clearFocus: () => {
flowPropPickerConfig.set(undefined)
}
}
flowPropPickerConfig.set({
...config,
clearFocus: () => {
flowPropPickerConfig.set(undefined)
}
})
}}
/>
{#if enableAi}
<IteratorGen
bind:this={iteratorGen}
focused={iteratorFieldFocused}
arg={mod.value.iterator}
on:showExpr={(e) => (suggestion = e.detail || undefined)}
on:setExpr={(e) => {
setExpr(e.detail)
}}
pickableProperties={stepPropPicker.pickableProperties}
/>
{/if}
</div>
{#if mod.value.iterator.type == 'javascript'}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="border rounded-md overflow-auto w-full"
id="flow-editor-iterator-expression"
onkeyup={iteratorGen?.onKeyUp}
>
<PropPickerWrapper
notSelectable
pickableProperties={stepPropPicker.pickableProperties}
on:select={({ detail }) => {
if ($flowPropPickerConfig) {
setExpr(detail)
flowPropPickerConfig.set(undefined)
return
}
editor?.insertAtCursor(detail)
editor?.focus()
}}
noPadding
>
<div class="relative w-full h-full overflow-clip">
<SimpleEditor
small
bind:this={editor}
on:focus={() => {
iteratorFieldFocused = true
}}
on:blur={() => {
iteratorFieldFocused = false
}}
lang="javascript"
bind:code={mod.value.iterator.expr}
class="h-full"
shouldBindKey={false}
extraLib={stepPropPicker.extraLib}
{suggestion}
/>
</div>
</PropPickerWrapper>
</div>
{:else}
<Button
on:click={() => {
if (mod.value.type === 'forloopflow') mod.value.iterator.type = 'javascript'
}}
/>
{/if}
{/if}
</Pane>
<Pane size={40} minSize={20} class="flex flex-col flex-1">
<TabsV2 bind:selected>
<!-- <Tab value="retries">Retries</Tab> -->
<Tab value="early-stop" label="Early Stop/Break" />
<Tab value="skip" label="Skip" />
<Tab value="suspend" label="Suspend/Approval/Prompt" />
<Tab value="sleep" label="Sleep" />
<Tab value="mock" label="Mock" />
<Tab value="lifetime" label="Lifetime" />
{#snippet content()}
<div class="overflow-hidden bg-surface" style="height:calc(100% - 32px);">
<!-- <TabContent value="retries" class="flex flex-col flex-1 h-full">
<div class="p-4 overflow-y-auto">
<FlowRetries bind:flowModule={mod} />
</div>
</TabContent> -->
<TabContent value="early-stop" class="flex flex-col flex-1 h-full">
<div class="p-4 overflow-y-auto">
<FlowModuleEarlyStop bind:flowModule={mod} />
</div>
</TabContent>
<TabContent value="skip" class="flex flex-col flex-1 h-full">
<div class="p-4 overflow-y-auto">
<FlowModuleSkip bind:flowModule={mod} {parentModule} {previousModule} />
</div>
</TabContent>
<TabContent value="suspend" class="flex flex-col flex-1 h-full">
<div class="p-4 overflow-y-auto">
<FlowModuleSuspend previousModuleId={previousModule?.id} bind:flowModule={mod} />
</div>
</TabContent>
<TabContent value="sleep" class="flex flex-col flex-1 h-full">
<div class="p-4 overflow-y-auto">
<FlowModuleSleep previousModuleId={previousModule?.id} bind:flowModule={mod} />
</div>
</TabContent>
<TabContent value="mock" class="flex flex-col flex-1 h-full">
<div class="p-4 overflow-y-auto">
<FlowModuleMock bind:flowModule={mod} />
</div>
</TabContent>
<TabContent value="lifetime" class="flex flex-col flex-1 h-full">
<div class="p-4 overflow-y-auto">
<FlowModuleDeleteAfterUse bind:flowModule={mod} disabled={!$enterpriseLicense} />
</div>
</TabContent>
</div>
{/snippet}
</TabsV2>
</Pane>
</Splitpanes>
</FlowCard>