Files
windmill/frontend/src/lib/components/FlowBuilder.svelte
T
ea74f584b1 refactor(frontend): capture refactor UI (#5591)
* migrate postgresEditorInner to svelte 5 syntaxe

* Add postgress

* add postgres capture section

* migrate kafka trigger config to svelte 5

* use snippets in kafka config

* feat: Update KafkaTriggerEditorInner with editMode, hideTarget and other props from WebsocketTriggerEditorInner

* feat: Create KafkaTriggerPanelV2 following WebsocketTriggersPanelV2 model

* feat: Integrate KafkaTriggerPanelV2 into TriggersEditorV2

* feat: add kafka capture panel

* use svelte 5 syntaxe for nats config

* use snippet in nats config

* feat: add editMode for nats trigger config

* fix import

* create NatsTriggerPanelV2.svelte for NATS trigger

* integrate NatsTriggerPanelV2 into TriggersEditorV2.svelte

* Create NatsCapture.svelte and update CaptureWrapperV2.svelte to use it

* migrate MqttTriggerEditorInner to svelte 5

* add mqtt trigger to new trigger panel

* migrate SqsTriggerEditorInner to svelte 5 syntaxe

* feat: add sqs triggers panel

* Add badge to MQTT config section

* remove unnecessary binding

* add gcp to the trigger list

* add prop size to GCP icon

* add gcp trigger utils

* migrate GcpTriggerEditorInner to svelte5 syntaxe

* add gcp trigger panel

* Revert "add gcp trigger utils"

This reverts commit 58f0df9985.

* fix type error

* add gcp to trigger bar

* add capture gcp panel

* add schedule poll panel

* feat: add trigger table horizontal bar

* add http trigger label

* make triggerbar vertical

* nit

* add trigger delete button for drafts

* enable edit from table for all triggers

* fix automatic edit mode for draft trigger

* fix proxy dispatching issue

* fix primary star positionning

* fix apply args to preprocessor

* automatically load bigpayloads

* clean capture wrapper code

* nit

* fix capture config not passed

* use Url component

* remove unused prop captureTable

* use new schedule panel for scheduled poll

* update triggersWrapperV2

* improve webhook capture panel

* remove debug log

* save draft with flow draft

* add path in draft label when available

* remove flowcard title

* use trigger label for trigger badge dropdown

* break from table to sidebar at 1000px

* remove triggers badge allways show sidebar behavior

* fix multiple primary addition

* improve primary schedule deploy logic

* remove debug logs

* gather imports

* add path for draft primary schedule

* handle redirection

* Add confirmation modal to deploy triggers

* remove debug log

* Save draft route trigger on deploy

* Allow draft for deployed triggers

* add component trigger editor toolbar

* improve update trigger utility function

* Add reset button for deployed draft triggers

* allow draft trigger for kafka triggers

* allow draft trigger for websocket triggers

* allow draft trigger for postgres triggers

* Add toggle to trigger toolbar

* allow draft for nats triggers

* allow draft for mqtt triggers

* allow draft sqs triggers

* allow draft for gcp triggers

* revert changes on the schedule editor

* update all triggers to use the same toolbar

* remove debug logs

* remove PrimarySchedule Panel and merge primary schedule logic with all triggers

* load schema from draft when editing a draft schedule

* prevent overflow in trigger table

* fix path saving for draft schedule

* fix trigger badge dropdown display

* get draft status from trigger

* remove unused event

* nit

* Add deploy badge to trigger

* replace checkbox with toggle for the modal

* fix selection of triggers with draft

* fix nit

* fix nit

* add missing config for loading schedule

* save schedule from config

* save http route from cfg

* save websocket trigger from cfg

* save postgres trigger from cfg

* save kafka trigger from cfg

* save sqs trigger from cfg

* save nats trigger from cfg

* save mqtt triggers from cfg

* save gcp trigger from config

* nit

* fix path update when editing a trigger

* remove unused saveCb

* fix trigger node navigation

* remove debug log

* add min size for trigger editor capture panel

* Integrate new triggers Editor to script builder

* remove previous triggersEdior page

* Remove former triggers Editor

* Migrate detail page to new triggers editor UI

* Remove unused dev TriggersBadge

* migrate triggers to V2

* Migrate capture components to V2

* improve triggers badge UI

* fix trigger panel with scheduled poll

* fix auto select all draft on deploy

* simplify permission check

* Add custom label to every trigger editor

* fix draft not deleting when saving triggers and add option to deploy now

* remove unused utility function

* fix triggers loading from draft

* make advanced route section collapsible

* handle subflow triggers display

* center triggers badge

* fix panel redirection when adding a new triggers from node

* Remove unnecessary alerts

* Fix proxy issue in schedule editor inner

* Remove unused schedule pages

* nit

* nit cli panel

* Migrate selectedTrigger store to V2

* handle extra header button for trigger editor panel

* prevent reload on toggle schedule enable

* remove unused utility function

* remove primaryScheduleStore

* handle triggerCount with drafts

* fix editing in Schedule Poll Mode

* Remove triggers default value context store

* fix triggers label max width

* Remove dbg logs

* improve no trigger selected message

* nit

* Do not show enable toggle for http triggers

* Adapt ui to dark mode

* nit

* remove captureOn context value

* improve dark mode

* fix nit

* fix merge issue

* nit

* nit

* fix schedule permission initialisation

* nit

* Disable deploy now if needed

* Improve utilitu function for handling with triggers

* improve trigger badge look

* fix kafka

* nit

* fix schedule display

* fix toggling edit mode

* fix edit/cancel edit

* nit

* prevent capture config update in editor

* migrate webhook panel to svelte 5

* fix postgres trigger creation

* fix bind issue in nats triggers

* fix modal triggers message flow/script

* fix apply args for script

* fix gcp triggers update when creating

* fix gcp capture

* nits

* add loading state for deploying all triggers

* remove default mqtt

* update base_endpoint for gcp

* nit

* fix

* fix

* remove base endpoint

* add catch in case of error while creating a trigger

* Add animation is capture panels

* show preprocessor extra when there is a preprocessor

* Add loading state for captures

* add missing fields for capture

* migrate URL to svelte 5

* show icon by default in confirmation modal

* do not show draft triggers in detail badge list

* migrate to new captures

* revert unwanted change

* migrate to preprocesor v2

* nit

* fix email capture

* nits and fix

* nits

* update repo ref

* Feat: auto save draft instad of save button

* use triggerIndex as selection

* Add modified tag to triggers

* Update trigger modal

* fix trigger node select

* fix schedule page

* migrate TriggersEditor to svelte 5

* remove unused props

* fix schedule editor init

* fix store reactivity issue

* use class instead of store for triggers context

* nit

* nit

* fix schedule init

* Add postgres error message

* nit

* fix deploy new script

* load schema in shedule editor

* improve transitions

* Improve capture indication

* revert unwanted changes

* improve update

* nit

* migrate captures section to svelte 5

* only show trash on hover triggers row

* Add token count

* improve trigger display

* Add relevent naming for triggers

* Use wrapper div insted of width binding in flow card

* add trigger sorting

* nit

* handle reload from URL for draft triggers

* dirty script and flow draft with draft triggers

* handle cloud hosted triggers

* nit

* nit

* fix nats reactivity

* remove debug log

* handle deployed draft triggers in the flow editor

* Add warning for event stream capture

---------

Co-authored-by: dieriba <dieriba.pro@gmail.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2025-05-21 23:45:19 +02:00

1511 lines
43 KiB
Svelte

<script lang="ts">
import {
FlowService,
type Flow,
type FlowModule,
DraftService,
type PathScript,
ScriptService,
type OpenFlow,
type RawScript,
type InputTransform,
type TriggersCount,
CaptureService
} from '$lib/gen'
import { initHistory, push, redo, undo } from '$lib/history'
import {
copilotInfo,
enterpriseLicense,
tutorialsToDo,
userStore,
workspaceStore,
usedTriggerKinds
} from '$lib/stores'
import {
cleanValueProperties,
encodeState,
generateRandomString,
orderedJsonStringify,
replaceFalseWithUndefined,
sleep,
type Value
} from '$lib/utils'
import { sendUserToast } from '$lib/toast'
import { Drawer } from '$lib/components/common'
import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte'
import { onMount, setContext, tick, type ComponentType } from 'svelte'
import { writable, type Writable } from 'svelte/store'
import CenteredPage from './CenteredPage.svelte'
import { Badge, Button, UndoRedo } from './common'
import FlowEditor from './flows/FlowEditor.svelte'
import ScriptEditorDrawer from './flows/content/ScriptEditorDrawer.svelte'
import type { FlowState } from './flows/flowState'
import { dfs as dfsApply } from './flows/dfs'
import { dfs, getPreviousIds } from './flows/previousResults'
import FlowImportExportMenu from './flows/header/FlowImportExportMenu.svelte'
import FlowPreviewButtons from './flows/header/FlowPreviewButtons.svelte'
import type { FlowEditorContext, FlowInput, FlowInputEditorState } from './flows/types'
import { cleanInputs, emptyFlowModuleState } from './flows/utils'
import { Calendar, Pen, Save, DiffIcon, HistoryIcon, FileJson, type Icon } from 'lucide-svelte'
import { createEventDispatcher } from 'svelte'
import Awareness from './Awareness.svelte'
import { getAllModules } from './flows/flowExplorer'
import {
stepCopilot,
type FlowCopilotModule,
glueCopilot,
type FlowCopilotContext
} from './copilot/flow'
import type { Schema, SchemaProperty } from '$lib/common'
import FlowCopilotDrawer from './copilot/FlowCopilotDrawer.svelte'
import FlowCopilotStatus from './copilot/FlowCopilotStatus.svelte'
import { fade } from 'svelte/transition'
import { loadFlowModuleState, pickScript } from './flows/flowStateUtils'
import FlowCopilotInputsModal from './copilot/FlowCopilotInputsModal.svelte'
import FlowBuilderTutorials from './FlowBuilderTutorials.svelte'
import Dropdown from '$lib/components/DropdownV2.svelte'
import FlowTutorials from './FlowTutorials.svelte'
import { ignoredTutorials } from './tutorials/ignoredTutorials'
import type DiffDrawer from './DiffDrawer.svelte'
import FlowHistory from './flows/FlowHistory.svelte'
import Summary from './Summary.svelte'
import type { FlowBuilderWhitelabelCustomUi } from './custom_ui'
import FlowYamlEditor from './flows/header/FlowYamlEditor.svelte'
import { type TriggerContext, type ScheduleTrigger } from './triggers'
import type { SavedAndModifiedValue } from './common/confirmationModal/unsavedTypes'
import DeployButton from './DeployButton.svelte'
import type { FlowWithDraftAndDraftTriggers, Trigger } from './triggers/utils'
import {
deployTriggers,
filterDraftTriggers,
handleSelectTriggerFromKind
} from './triggers/utils'
import DraftTriggersConfirmationModal from './common/confirmationModal/DraftTriggersConfirmationModal.svelte'
import { Triggers } from './triggers/triggers.svelte'
export let initialPath: string = ''
export let pathStoreInit: string | undefined = undefined
export let newFlow: boolean
export let selectedId: string | undefined
export let initialArgs: Record<string, any> = {}
export let loading = false
export let flowStore: Writable<OpenFlow>
export let flowStateStore: Writable<FlowState>
export let savedFlow: FlowWithDraftAndDraftTriggers | undefined = undefined
export let diffDrawer: DiffDrawer | undefined = undefined
export let customUi: FlowBuilderWhitelabelCustomUi = {}
export let disableAi: boolean = false
export let disabledFlowInputs = false
export let savedPrimarySchedule: ScheduleTrigger | undefined = undefined // used to set the primary schedule in the legacy primaryScheduleStore
export let version: number | undefined = undefined
export let setSavedraftCb: ((cb: () => void) => void) | undefined = undefined
export let draftTriggersFromUrl: Trigger[] | undefined = undefined
export let selectedTriggerIndexFromUrl: number | undefined = undefined
let initialPathStore = writable(initialPath)
$: initialPathStore.set(initialPath)
// used for new flows for captures
let fakeInitialPath =
'u/' +
($userStore?.username?.includes('@')
? $userStore!.username.split('@')[0].replace(/[^a-zA-Z0-9_]/g, '')
: $userStore!.username!) +
'/' +
generateRandomString(12)
// Used by multiplayer deploy collision warning
let deployedValue: Value | undefined = undefined // Value to diff against
let deployedBy: string | undefined = undefined // Author
let confirmCallback: () => void = () => {} // What happens when user clicks `override` in warning
let open: boolean = false // Is confirmation modal open
// Draft triggers confirmation modal
let draftTriggersModalOpen = false
let confirmDeploymentCallback: (triggersToDeploy: Trigger[]) => void = () => {}
async function handleDraftTriggersConfirmed(event: CustomEvent<{ selectedTriggers: Trigger[] }>) {
const { selectedTriggers } = event.detail
// Continue with saving the flow
draftTriggersModalOpen = false
confirmDeploymentCallback(selectedTriggers)
}
$: setContext('customUi', customUi)
export function getInitialAndModifiedValues(): SavedAndModifiedValue {
return {
savedValue: savedFlow,
modifiedValue: {
...$flowStore,
draft_triggers: structuredClone(triggersState.getDraftTriggersSnapshot())
}
}
}
let onLatest = true
async function compareVersions() {
if (version === undefined) {
return
}
try {
if (initialPath && initialPath != '') {
const flowVersion = await FlowService.getFlowLatestVersion({
workspace: $workspaceStore!,
path: initialPath
})
onLatest = version === flowVersion?.id
} else {
onLatest = true
}
} catch (err) {
console.error('Error comparing versions', err)
onLatest = true
}
}
const dispatch = createEventDispatcher()
const primaryScheduleStore = writable<ScheduleTrigger | undefined | false>(savedPrimarySchedule) // kept for legacy reasons
const triggersCount = writable<TriggersCount | undefined>(undefined)
const simplifiedPoll = writable(false)
// used to set the primary schedule in the legacy primaryScheduleStore
export function setPrimarySchedule(schedule: ScheduleTrigger | undefined | false) {
primaryScheduleStore.set(schedule)
}
export function setDraftTriggers(triggers: Trigger[] | undefined) {
triggersState.setTriggers([
...(triggers ?? []),
...triggersState.triggers.filter((t) => !t.draftConfig)
])
loadTriggers()
}
export function setSelectedTriggerIndex(index: number | undefined) {
triggersState.selectedTriggerIndex = index
}
let loadingSave = false
let loadingDraft = false
export async function saveDraft(forceSave = false): Promise<void> {
if (!newFlow && !savedFlow) {
return
}
if (savedFlow) {
const draftOrDeployed = cleanValueProperties(savedFlow.draft || savedFlow)
const currentDraftTriggers = structuredClone(triggersState.getDraftTriggersSnapshot())
const current = cleanValueProperties({
...$flowStore,
path: $pathStore,
draft_triggers: currentDraftTriggers
})
if (!forceSave && orderedJsonStringify(draftOrDeployed) === orderedJsonStringify(current)) {
sendUserToast('No changes detected, ignoring', false, [
{
label: 'Save anyway',
callback: () => {
saveDraft(true)
}
}
])
return
}
}
loadingDraft = true
try {
const flow = cleanInputs($flowStore)
try {
localStorage.removeItem('flow')
localStorage.removeItem(`flow-${$pathStore}`)
} catch (e) {
console.error('error interacting with local storage', e)
}
if (newFlow || savedFlow?.draft_only) {
if (savedFlow?.draft_only) {
await FlowService.deleteFlowByPath({
workspace: $workspaceStore!,
path: initialPath,
keepCaptures: true
})
}
if (!initialPath || $pathStore != initialPath) {
await CaptureService.moveCapturesAndConfigs({
workspace: $workspaceStore!,
path: initialPath || fakeInitialPath,
requestBody: {
new_path: $pathStore
},
runnableKind: 'flow'
})
}
await FlowService.createFlow({
workspace: $workspaceStore!,
requestBody: {
path: $pathStore,
summary: flow.summary ?? '',
description: flow.description ?? '',
value: flow.value,
schema: flow.schema,
tag: flow.tag,
draft_only: true,
ws_error_handler_muted: flow.ws_error_handler_muted,
visible_to_runner_only: flow.visible_to_runner_only,
on_behalf_of_email: flow.on_behalf_of_email
}
})
}
await DraftService.createDraft({
workspace: $workspaceStore!,
requestBody: {
path: newFlow || savedFlow?.draft_only ? $pathStore : initialPath,
typ: 'flow',
value: {
...flow,
path: $pathStore,
draft_triggers: triggersState.getDraftTriggersSnapshot()
}
}
})
savedFlow = {
...(newFlow || savedFlow?.draft_only
? {
...structuredClone($flowStore),
path: $pathStore,
draft_only: true
}
: savedFlow),
draft: {
...structuredClone($flowStore),
path: $pathStore,
draft_triggers: structuredClone(triggersState.getDraftTriggersSnapshot())
}
} as FlowWithDraftAndDraftTriggers
let savedAtNewPath = false
if (newFlow) {
dispatch('saveInitial', $pathStore)
} else if (savedFlow?.draft_only && $pathStore !== initialPath) {
savedAtNewPath = true
initialPath = $pathStore
// this is so we can use the flow builder outside of sveltekit
dispatch('saveDraftOnlyAtNewPath', { path: $pathStore, selectedId: getSelectedId() })
}
dispatch('saveDraft', { path: $pathStore, savedAtNewPath, newFlow })
sendUserToast('Saved as draft')
} catch (error) {
sendUserToast(`Error while saving the flow as a draft: ${error.body || error.message}`, true)
dispatch('saveDraftError', error)
}
loadingDraft = false
}
onMount(() => {
setSavedraftCb?.(() => saveDraft())
})
export function computeUnlockedSteps(flow: Flow) {
return Object.fromEntries(
getAllModules(flow.value.modules, flow.value.failure_module)
.filter((m) => m.value.type == 'script' && m.value.hash == null)
.map((m) => [m.id, (m.value as PathScript).path])
)
}
async function handleSaveFlow(deploymentMsg?: string) {
await compareVersions()
if (onLatest || initialPath == '' || savedFlow?.draft_only) {
// Handle directly
await saveFlow(deploymentMsg)
} else {
// We need it for diff
await syncWithDeployed()
if (
deployedValue &&
$flowStore &&
orderedJsonStringify(deployedValue) ===
orderedJsonStringify(replaceFalseWithUndefined({ ...$flowStore, path: $pathStore }))
) {
await saveFlow(deploymentMsg)
} else {
// Handle through confirmation modal
confirmCallback = async () => {
await saveFlow(deploymentMsg)
}
// Open confirmation modal
open = true
}
}
}
async function syncWithDeployed() {
const flow = await FlowService.getFlowByPath({
workspace: $workspaceStore!,
path: initialPath,
withStarredInfo: true
})
deployedValue = replaceFalseWithUndefined({
...flow,
edited_at: undefined,
edited_by: undefined,
workspace_id: undefined
})
deployedBy = flow.edited_by
}
async function saveFlow(deploymentMsg?: string, triggersToDeploy?: Trigger[]): Promise<void> {
if (!triggersToDeploy) {
// Check if there are draft triggers that need confirmation
const draftTriggers = triggersState.triggers.filter((trigger) => trigger.draftConfig)
if (draftTriggers.length > 0) {
draftTriggersModalOpen = true
confirmDeploymentCallback = async (triggersToDeploy: Trigger[]) => {
await saveFlow(deploymentMsg, triggersToDeploy)
}
return
}
}
loadingSave = true
try {
const flow = cleanInputs($flowStore)
// console.log('flow', computeUnlockedSteps(flow)) // del
// loadingSave = false // del
// return
if (newFlow) {
try {
localStorage.removeItem('flow')
localStorage.removeItem(`flow-${$pathStore}`)
} catch (e) {
console.error('error interacting with local storage', e)
}
await FlowService.createFlow({
workspace: $workspaceStore!,
requestBody: {
path: $pathStore,
summary: flow.summary ?? '',
description: flow.description ?? '',
value: flow.value,
schema: flow.schema,
ws_error_handler_muted: flow.ws_error_handler_muted,
tag: flow.tag,
dedicated_worker: flow.dedicated_worker,
visible_to_runner_only: flow.visible_to_runner_only,
on_behalf_of_email: flow.on_behalf_of_email,
deployment_message: deploymentMsg || undefined
}
})
await CaptureService.moveCapturesAndConfigs({
workspace: $workspaceStore!,
path: fakeInitialPath,
requestBody: {
new_path: $pathStore
},
runnableKind: 'flow'
})
if (triggersToDeploy) {
await deployTriggers(
triggersToDeploy,
$workspaceStore,
!!$userStore?.is_admin || !!$userStore?.is_super_admin,
usedTriggerKinds,
$pathStore,
true
)
}
} else {
try {
localStorage.removeItem(`flow-${initialPath}`)
} catch (e) {
console.error('error interacting with local storage', e)
}
if (triggersToDeploy) {
await deployTriggers(
triggersToDeploy,
$workspaceStore,
!!$userStore?.is_admin || !!$userStore?.is_super_admin,
usedTriggerKinds,
initialPath
)
}
await FlowService.updateFlow({
workspace: $workspaceStore!,
path: initialPath,
requestBody: {
path: $pathStore,
summary: flow.summary,
description: flow.description ?? '',
value: flow.value,
schema: flow.schema,
tag: flow.tag,
dedicated_worker: flow.dedicated_worker,
ws_error_handler_muted: flow.ws_error_handler_muted,
visible_to_runner_only: flow.visible_to_runner_only,
on_behalf_of_email: flow.on_behalf_of_email,
deployment_message: deploymentMsg || undefined
}
})
}
const { draft_triggers: _, ...newSavedFlow } = $flowStore as OpenFlow & {
draft_triggers: Trigger[]
}
savedFlow = {
...structuredClone(newSavedFlow),
path: $pathStore
} as Flow
triggersState.setTriggers([])
loadingSave = false
dispatch('deploy', $pathStore)
} catch (err) {
dispatch('deployError', err)
sendUserToast(`The flow could not be saved: ${err.body}`, true)
loadingSave = false
}
}
let timeout: NodeJS.Timeout | undefined = undefined
$: {
if ($flowStore || $selectedIdStore) {
saveSessionDraft()
}
}
function saveSessionDraft() {
timeout && clearTimeout(timeout)
timeout = setTimeout(() => {
try {
localStorage.setItem(
initialPath && initialPath != '' ? `flow-${initialPath}` : 'flow',
encodeState({
flow: $flowStore,
path: $pathStore,
selectedId: $selectedIdStore,
draft_triggers: triggersState.getDraftTriggersSnapshot(),
selected_trigger: triggersState.getSelectedTriggerSnapshot()
})
)
} catch (err) {
console.error(err)
}
}, 500)
}
const selectedIdStore = writable<string>(selectedId ?? 'settings-metadata')
export function getSelectedId() {
return $selectedIdStore
}
const previewArgsStore = writable<Record<string, any>>(initialArgs)
const scriptEditorDrawer = writable<ScriptEditorDrawer | undefined>(undefined)
const moving = writable<{ module: FlowModule; modules: FlowModule[] } | undefined>(undefined)
const history = initHistory($flowStore)
const pathStore = writable<string>(pathStoreInit ?? initialPath)
const captureOn = writable<boolean>(false)
const showCaptureHint = writable<boolean | undefined>(undefined)
const flowInputEditorStateStore = writable<FlowInputEditorState>({
selectedTab: undefined,
editPanelSize: 0,
payloadData: undefined
})
$: initialPath && ($pathStore = initialPath)
const testStepStore = writable<Record<string, any>>({})
function select(selectedId: string) {
selectedIdStore.set(selectedId)
}
let insertButtonOpen = writable<boolean>(false)
setContext<FlowEditorContext>('FlowEditorContext', {
selectedId: selectedIdStore,
previewArgs: previewArgsStore,
scriptEditorDrawer,
moving,
history,
flowStateStore,
flowStore,
pathStore,
testStepStore,
saveDraft,
initialPathStore,
fakeInitialPath,
flowInputsStore: writable<FlowInput>({}),
customUi,
insertButtonOpen,
executionCount: writable(0),
flowInputEditorState: flowInputEditorStateStore
})
// Add triggers context store
const triggersState = new Triggers(
[
{ type: 'webhook', path: '', isDraft: false },
{ type: 'email', path: '', isDraft: false },
...(draftTriggersFromUrl ?? savedFlow?.draft?.draft_triggers ?? [])
],
selectedTriggerIndexFromUrl,
saveSessionDraft
)
setContext<TriggerContext>('TriggerContext', {
triggersCount,
simplifiedPoll,
showCaptureHint,
triggersState
})
export async function loadTriggers() {
$triggersCount = await FlowService.getTriggersCountOfFlow({
workspace: $workspaceStore!,
path: initialPath
})
// Initialize triggers using utility function
await triggersState.fetchTriggers(
triggersCount,
$workspaceStore,
initialPath,
true,
$primaryScheduleStore,
$userStore
)
if (savedFlow && savedFlow.draft) {
savedFlow = filterDraftTriggers(savedFlow, triggersState) as FlowWithDraftAndDraftTriggers
}
}
$: selectedId && select(selectedId)
$: initialPath && initialPath != '' && $workspaceStore && loadTriggers()
function onKeyDown(event: KeyboardEvent) {
let classes = event.target?.['className']
if (
(typeof classes === 'string' && classes.includes('inputarea')) ||
['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName!)
) {
return
}
switch (event.key) {
case 'Z':
if (event.ctrlKey || event.metaKey) {
$flowStore = redo(history)
event.preventDefault()
}
break
case 'z':
if (event.ctrlKey || event.metaKey) {
$flowStore = undo(history, $flowStore)
$selectedIdStore = 'Input'
event.preventDefault()
}
break
case 's':
if (event.ctrlKey || event.metaKey) {
saveDraft()
event.preventDefault()
}
break
case 'ArrowDown': {
if (!$insertButtonOpen) {
let ids = generateIds()
let idx = ids.indexOf($selectedIdStore)
if (idx > -1 && idx < ids.length - 1) {
$selectedIdStore = ids[idx + 1]
event.preventDefault()
}
}
break
}
case 'ArrowUp': {
if (!$insertButtonOpen) {
let ids = generateIds()
let idx = ids.indexOf($selectedIdStore)
if (idx > 0 && idx < ids.length) {
$selectedIdStore = ids[idx - 1]
event.preventDefault()
}
}
break
}
}
}
function generateIds() {
return [
'settings-metadata',
'constants',
'preprocessor',
...dfsApply($flowStore.value.modules, (module) => module.id)
]
}
const dropdownItems: Array<{
label: string
onClick: () => void
}> = []
if (customUi.topBar?.extraDeployOptions != false) {
if (savedFlow?.draft_only === false || savedFlow?.draft_only === undefined) {
dropdownItems.push({
label: 'Exit & see details',
onClick: () => dispatch('details', $pathStore)
})
}
if (!newFlow) {
dropdownItems.push({
label: 'Fork',
onClick: () => window.open(`/flows/add?template=${initialPath}`)
})
}
}
let flowCopilotContext: FlowCopilotContext = {
drawerStore: writable<Drawer | undefined>(undefined),
modulesStore: writable<FlowCopilotModule[]>([]),
currentStepStore: writable<string | undefined>(undefined),
genFlow: undefined,
shouldUpdatePropertyType: writable<{
[key: string]: 'static' | 'javascript' | undefined
}>({}),
exprsToSet: writable<{
[key: string]: InputTransform | any | undefined
}>({}),
generatedExprs: writable<{
[key: string]: string | undefined
}>({}),
stepInputsLoading: writable<boolean>(false)
}
setContext('FlowCopilotContext', flowCopilotContext)
const {
drawerStore: copilotDrawerStore,
modulesStore: copilotModulesStore,
currentStepStore: copilotCurrentStepStore,
shouldUpdatePropertyType
} = flowCopilotContext
let doneTs = 0
async function getHubCompletions(text: string, idx: number, type: 'trigger' | 'script') {
try {
// make sure we display the results of the last request last
const ts = Date.now()
const scripts = (
await ScriptService.queryHubScripts({
text: `${text}`,
limit: 3,
kind: type
})
).map((s) => ({
...s,
path: `hub/${s.version_id}/${s.app}/${s.summary.toLowerCase().replaceAll(/\s+/g, '_')}`
}))
if (ts < doneTs) return
doneTs = ts
$copilotModulesStore[idx].hubCompletions = scripts as {
path: string
summary: string
kind: string
app: string
ask_id: number
id: number
version_id: number
}[]
} catch (err) {
if (err.name !== 'CancelError') throw err
}
}
let abortController: AbortController | undefined = undefined
let copilotLoading = false
let flowCopilotMode: 'trigger' | 'sequence' = 'sequence'
let copilotStatus: string = ''
let copilotFlowInputs: Record<string, SchemaProperty> = {}
let copilotFlowRequiredInputs: string[] = []
let openCopilotInputsModal = false
function setInitCopilotModules(mode: typeof flowCopilotMode) {
$copilotModulesStore = [
{
id: 'a',
type: mode === 'trigger' ? 'trigger' : 'script',
description: '',
code: '',
hubCompletions: [],
selectedCompletion: undefined,
source: undefined,
lang: undefined
},
{
id: 'b',
type: 'script',
description: '',
code: '',
hubCompletions: [],
selectedCompletion: undefined,
source: undefined,
lang: undefined
}
]
}
$: setInitCopilotModules(flowCopilotMode)
function applyCopilotFlowInputs() {
const properties = {
...($flowStore.schema?.properties as Record<string, SchemaProperty> | undefined),
...copilotFlowInputs
}
const required = [
...(($flowStore.schema?.required as string[] | undefined) ?? []),
...copilotFlowRequiredInputs
]
$flowStore.schema = {
$schema: 'https://json-schema.org/draft/2020-12/schema',
properties,
required,
type: 'object'
}
}
function clearFlowInputsFromStep(id: string | undefined) {
const module: FlowModule | undefined = dfs(id, $flowStore)[0]
if (module?.value.type === 'rawscript' || module?.value.type === 'script') {
// clear step inputs that start with flow_input. but not flow_input.iter
for (const key in module.value.input_transforms) {
const input = module.value.input_transforms[key]
if (
input.type === 'javascript' &&
input.expr.includes('flow_input.') &&
!input.expr.includes('flow_input.iter')
) {
module.value.input_transforms[key] = {
type: 'static',
value: undefined
}
$shouldUpdatePropertyType[key] = 'static'
}
}
}
$flowStore = $flowStore
}
async function finishStepGen() {
copilotFlowInputs = {}
copilotFlowRequiredInputs = []
setInitCopilotModules(flowCopilotMode)
copilotStatus = "Done! Just check the step's inputs and you're good to go!"
await sleep(3000)
copilotStatus = ''
}
function snakeCase(e: string): string {
if (e.toLowerCase() === e) {
return e
}
return (
e
.match(/([A-Z])/g)
?.reduce((str, c) => str.replace(new RegExp(c), '_' + c.toLowerCase()), e)
?.substring(e.slice(0, 1).match(/([A-Z])/g) ? 1 : 0) ?? e
)
}
async function genFlow(idx: number, flowModules: FlowModule[], stepOnly = false) {
try {
push(history, $flowStore)
let module = stepOnly ? $copilotModulesStore[0] : $copilotModulesStore[idx]
copilotLoading = true
copilotStatus = "Generating code for step '" + module.id + "'..."
$copilotCurrentStepStore = module.id
focusCopilot()
if (!stepOnly && flowModules.length > idx) {
select('')
await tick()
flowModules.splice(idx, flowModules.length - idx)
$flowStore = $flowStore
focusCopilot()
}
if (idx === 0 && !stepOnly) {
$flowStore.schema = {
$schema: 'https://json-schema.org/draft/2020-12/schema',
properties: {},
required: [],
type: 'object'
}
}
const flowModule: FlowModule & {
value: RawScript | PathScript
} = {
id: module.id,
stop_after_if:
module.type === 'trigger'
? {
expr: 'result == undefined || Array.isArray(result) && result.length == 0',
skip_if_stopped: true
}
: undefined,
value: {
input_transforms: {},
content: '',
language: module.lang ?? 'bun',
type: 'rawscript'
},
summary: module.description
}
let isHubStep = false
if (module.source === 'hub' && module.selectedCompletion) {
isHubStep = true
const [hubScriptModule, hubScriptState] = await pickScript(
module.selectedCompletion.path,
`${module.selectedCompletion.summary} (${module.selectedCompletion.app})`,
module.id,
undefined
)
flowModule.value = hubScriptModule.value
flowModule.summary = hubScriptModule.summary
$flowStateStore[module.id] = hubScriptState
} else {
$flowStateStore[module.id] = emptyFlowModuleState()
}
if (stepOnly) {
flowModules.splice(idx, 0, flowModule)
} else if (idx === 1 && $copilotModulesStore[idx - 1].type === 'trigger') {
const loopModule: FlowModule = {
id: module.id + '_loop',
value: {
type: 'forloopflow',
iterator: {
type: 'javascript',
expr: 'results.a'
},
skip_failures: true,
modules: [flowModule]
}
}
const loopState = await loadFlowModuleState(loopModule)
$flowStateStore[loopModule.id] = loopState
flowModules.push(loopModule)
} else {
flowModules.push(flowModule)
}
$copilotDrawerStore?.closeDrawer()
await tick()
select(module.id)
await tick()
focusCopilot()
let isFirstInLoop = false
const parents = dfs(module.id, $flowStore).slice(1)
if (
parents[0]?.value.type === 'forloopflow' &&
parents[0].value.modules[0].id === module.id
) {
isFirstInLoop = true
}
const prevNodeId = getPreviousIds(module.id, $flowStore, false)[0] as string | undefined
const pastModule = dfs(prevNodeId, $flowStore, false)[0] as FlowModule | undefined
if (!module.source) {
throw new Error('Invalid copilot module source')
}
if (module.source === 'custom') {
const deltaStore = writable<string>('')
const unsubscribe = deltaStore.subscribe(async (delta) => {
module.editor?.append(delta)
})
abortController = new AbortController()
await stepCopilot(
module,
deltaStore,
$workspaceStore!,
pastModule?.value.type === 'rawscript' || pastModule?.value.type === 'script'
? (pastModule as FlowModule & {
value: RawScript | PathScript
})
: undefined,
isFirstInLoop,
abortController
)
unsubscribe()
}
copilotStatus = "Generating inputs for step '" + module.id + "'..."
await sleep(500) // make sure code was parsed
try {
if (
(flowModule.value.type === 'rawscript' || flowModule.value.type === 'script') &&
(pastModule === undefined ||
pastModule.value.type === 'rawscript' ||
pastModule.value.type === 'script')
) {
const stepSchema: Schema = JSON.parse(JSON.stringify($flowStateStore[module.id].schema)) // deep copy
if (isHubStep && pastModule !== undefined && $copilotInfo.enabled) {
// ask AI to set step inputs
abortController = new AbortController()
const { inputs, allExprs } = await glueCopilot(
flowModule.value.input_transforms,
$workspaceStore!,
pastModule as FlowModule & {
value: RawScript | PathScript
},
isFirstInLoop,
abortController
)
// create flow inputs used by AI for autocompletion
copilotFlowInputs = {}
copilotFlowRequiredInputs = []
Object.entries(allExprs).forEach(([key, expr]) => {
if (expr.includes('flow_input.') && !expr.includes('flow_input.iter.')) {
const flowInputKey = expr.match(/flow_input\.([A-Za-z0-9_]+)/)?.[1]
if (
flowInputKey !== undefined &&
(!$flowStore.schema ||
!(flowInputKey in (($flowStore.schema.properties as any) ?? {}))) // prevent overriding flow inputs
) {
if (key in stepSchema.properties) {
copilotFlowInputs[flowInputKey] = stepSchema.properties[key]
if (stepSchema.required.includes(key)) {
copilotFlowRequiredInputs.push(flowInputKey)
}
} else {
// when the key is nested (e.g. body.content)
const [firstKey, ...rest] = key.split('.')
const restKey = rest.join('.')
const firstKeyProperties = stepSchema.properties[firstKey]?.properties
if (firstKeyProperties !== undefined && restKey in firstKeyProperties) {
copilotFlowInputs[flowInputKey] = firstKeyProperties[restKey]
if (firstKeyProperties[restKey].required?.includes(flowInputKey)) {
copilotFlowRequiredInputs.push(flowInputKey)
}
}
}
}
}
})
if (!stepOnly) {
applyCopilotFlowInputs()
}
// set step inputs
Object.entries(inputs).forEach(([key, expr]) => {
flowModule.value.input_transforms[key] = {
type: 'javascript',
expr
}
$shouldUpdatePropertyType[key] = 'javascript'
})
} else {
if (isHubStep && pastModule !== undefined && !$copilotInfo.enabled) {
sendUserToast(
'For better input generation, enable Windmill AI in the workspace settings',
true
)
}
// create possible flow inputs for autocompletion
copilotFlowInputs = {}
copilotFlowRequiredInputs = []
Object.keys(flowModule.value.input_transforms).forEach((key) => {
if (key !== 'prev_output') {
const schema = $flowStateStore[module.id].schema
const schemaProperty = Object.entries(schema?.properties ?? {}).find(
(x) => x[0] === key
)?.[1]
const snakeKey = snakeCase(key)
if (
schemaProperty &&
(!$flowStore.schema ||
!(snakeKey in ($flowStore?.schema?.properties ?? ({} as any)))) // prevent overriding flow inputs
) {
copilotFlowInputs[snakeKey] = schemaProperty
if (schema?.required.includes(snakeKey)) {
copilotFlowRequiredInputs.push(snakeKey)
}
}
}
})
if (!stepOnly) {
applyCopilotFlowInputs()
}
// programatically set step inputs
for (const key of Object.keys(flowModule.value.input_transforms)) {
const snakeKey = snakeCase(key)
flowModule.value.input_transforms[key] = {
type: 'javascript',
expr:
key === 'prev_output'
? isFirstInLoop
? 'flow_input.iter.value'
: pastModule
? 'results.' + pastModule.id
: 'flow_input.' + snakeKey
: 'flow_input.' + snakeKey
}
$shouldUpdatePropertyType[key] = 'javascript'
}
}
$flowStore = $flowStore // force rerendering
} else {
if (
pastModule !== undefined &&
pastModule.value.type !== 'rawscript' &&
pastModule.value.type !== 'script'
) {
sendUserToast(
`Linking to previous step ${pastModule.id} of type ${pastModule.value.type} is not yet supported`,
true
)
} else {
sendUserToast('Something went wrong, could not generate step inputs', true)
}
}
} catch (err) {
console.error(err)
}
if (stepOnly) {
$copilotCurrentStepStore = undefined
copilotLoading = false
copilotStatus = ''
if (Object.keys(copilotFlowInputs).length > 0) {
openCopilotInputsModal = true
} else {
finishStepGen()
}
} else {
copilotStatus =
"Waiting for the user to validate code and inputs of step '" + module.id + "'"
}
} catch (err) {
if (stepOnly) {
copilotStatus = ''
$copilotCurrentStepStore = undefined
setInitCopilotModules(flowCopilotMode)
}
if (err?.message) {
sendUserToast('Failed to generate code: ' + err.message, true)
} else {
sendUserToast('Failed to generate code', true)
console.error(err)
}
} finally {
copilotLoading = false
}
}
flowCopilotContext.genFlow = genFlow
async function finishCopilotFlowBuilder() {
copilotLoading = true
select('Input')
$copilotCurrentStepStore = 'Input'
copilotStatus = "Done! Just check the flow's inputs and you're good to go!"
$copilotCurrentStepStore = undefined
copilotLoading = false
await sleep(3000)
copilotStatus = ''
}
function focusCopilot() {
document.querySelectorAll('.splitpanes__splitter').forEach((el) => {
el.classList.add('hidden')
})
document.querySelectorAll('#flow-graph *').forEach((el) => {
if (el instanceof HTMLElement) {
el.style.pointerEvents = 'none'
}
})
}
function blurCopilot() {
document.querySelectorAll('.splitpanes__splitter').forEach((el) => {
el.classList.remove('hidden')
})
document.querySelectorAll('#flow-graph *').forEach((el) => {
if (el instanceof HTMLElement) {
el.style.pointerEvents = ''
}
})
}
$: $copilotCurrentStepStore === undefined && blurCopilot()
let renderCount = 0
let flowTutorials: FlowTutorials | undefined = undefined
let jsonViewerDrawer: Drawer | undefined = undefined
let yamlEditorDrawer: Drawer | undefined = undefined
let flowHistory: FlowHistory | undefined = undefined
export function triggerTutorial() {
const urlParams = new URLSearchParams(window.location.search)
const tutorial = urlParams.get('tutorial')
if (tutorial) {
flowTutorials?.runTutorialById(tutorial)
} else if ($tutorialsToDo.includes(0) && !$ignoredTutorials.includes(0)) {
flowTutorials?.runTutorialById('action')
}
}
let moreItems: {
displayName: string
icon: ComponentType<Icon>
action: () => void
disabled?: boolean
}[] = []
$: onCustomUiChange(customUi)
function onCustomUiChange(customUi: FlowBuilderWhitelabelCustomUi | undefined) {
moreItems = [
...(customUi?.topBar?.history != false
? [
{
displayName: 'Deployment History',
icon: HistoryIcon,
action: () => {
flowHistory?.open()
},
disabled: newFlow
}
]
: []),
...(customUi?.topBar?.history != false
? [
{
displayName: 'Export',
icon: FileJson,
action: () => jsonViewerDrawer?.openDrawer()
},
{
displayName: 'Edit in YAML',
icon: FileJson,
action: () => yamlEditorDrawer?.openDrawer()
}
]
: [])
]
}
function handleDeployTrigger(trigger: Trigger) {
const { id, path, type } = trigger
//Update the saved flow to remove the draft trigger that is deployed
if (savedFlow && savedFlow.draft && savedFlow.draft.draft_triggers) {
const newSavedDraftTrigers = savedFlow.draft.draft_triggers.filter(
(t) => t.id !== id || t.path !== path || t.type !== type
)
savedFlow.draft.draft_triggers =
newSavedDraftTrigers.length > 0 ? newSavedDraftTrigers : undefined
}
}
let flowPreviewButtons: FlowPreviewButtons
</script>
<svelte:window on:keydown={onKeyDown} />
<slot />
<DeployOverrideConfirmationModal
bind:deployedBy
bind:confirmCallback
bind:open
{diffDrawer}
bind:deployedValue
currentValue={$flowStore}
/>
<DraftTriggersConfirmationModal
bind:open={draftTriggersModalOpen}
draftTriggers={triggersState.triggers.filter((t) => t.draftConfig)}
isFlow={true}
on:canceled={() => {
draftTriggersModalOpen = false
}}
on:confirmed={handleDraftTriggersConfirmed}
/>
{#key renderCount}
{#if !$userStore?.operator}
<FlowCopilotDrawer {getHubCompletions} {genFlow} bind:flowCopilotMode />
{#if $pathStore}
<FlowHistory bind:this={flowHistory} path={$pathStore} on:historyRestore />
{/if}
<FlowYamlEditor bind:drawer={yamlEditorDrawer} />
<FlowImportExportMenu bind:drawer={jsonViewerDrawer} />
<FlowCopilotInputsModal
on:confirmed={async () => {
applyCopilotFlowInputs()
finishStepGen()
}}
on:canceled={async () => {
clearFlowInputsFromStep($copilotModulesStore[0]?.id)
finishStepGen()
}}
bind:open={openCopilotInputsModal}
inputs={Object.keys(copilotFlowInputs)}
/>
<ScriptEditorDrawer bind:this={$scriptEditorDrawer} />
<div class="flex flex-col flex-1 h-screen">
<!-- Nav between steps-->
<div
class="justify-between flex flex-row items-center pl-2.5 pr-6 space-x-4 scrollbar-hidden overflow-x-auto max-h-12 h-full relative"
>
{#if $copilotCurrentStepStore !== undefined}
<div transition:fade class="absolute inset-0 bg-gray-500 bg-opacity-75 z-[900] !m-0"
></div>
{/if}
<div class="flex w-full max-w-md gap-4 items-center">
<Summary
disabled={customUi?.topBar?.editableSummary == false}
bind:value={$flowStore.summary}
/>
<UndoRedo
undoProps={{ disabled: $history.index === 0 }}
redoProps={{ disabled: $history.index === $history.history.length - 1 }}
on:undo={() => {
const currentModules = $flowStore?.value?.modules
$flowStore = undo(history, $flowStore)
const newModules = $flowStore?.value?.modules
const restoredModules = newModules?.filter(
(node) => !currentModules?.some((currentNode) => currentNode?.id === node?.id)
)
for (const mod of restoredModules) {
if (mod) {
try {
loadFlowModuleState(mod).then((state) => ($flowStateStore[mod.id] = state))
} catch (e) {
console.error('Error loading state for restored node', e)
}
}
}
$selectedIdStore = 'Input'
}}
on:redo={() => {
$flowStore = redo(history)
}}
/>
</div>
<div class="gap-4 flex-row hidden md:flex w-full max-w-md">
{#if triggersState.triggers?.some((t) => t.type === 'schedule')}
{@const primaryScheduleIndex = triggersState.triggers.findIndex((t) => t.isPrimary)}
{@const scheduleIndex = triggersState.triggers.findIndex((t) => t.type === 'schedule')}
<Button
btnClasses="hidden lg:inline-flex"
startIcon={{ icon: Calendar }}
variant="contained"
color="light"
size="xs"
on:click={async () => {
select('triggers')
const selected = primaryScheduleIndex ?? scheduleIndex
if (selected) {
triggersState.selectedTriggerIndex = selected
}
}}
>
{triggersState.triggers[primaryScheduleIndex]?.draftConfig?.schedule ??
triggersState.triggers[primaryScheduleIndex]?.lightConfig?.schedule ??
''}
</Button>
{/if}
{#if customUi?.topBar?.path != false}
<div class="flex justify-start w-full">
<div>
<button
on:click={async () => {
select('settings-metadata')
document.getElementById('path')?.focus()
}}
>
<Badge
color="gray"
class="center-center !bg-gray-300 !text-tertiary dark:!bg-gray-700 dark:!text-gray-300 !h-[28px] !w-[70px] rounded-r-none"
>
<Pen size={12} class="mr-2" /> Path
</Badge>
</button>
</div>
<input
type="text"
readonly
value={$pathStore && $pathStore != '' ? $pathStore : 'Choose a path'}
class="font-mono !text-xs !min-w-[96px] !max-w-[300px] !w-full !h-[28px] !my-0 !py-0 !border-l-0 !rounded-l-none"
on:focus={({ currentTarget }) => {
currentTarget.select()
}}
/>
</div>
{/if}
</div>
<div class="flex flex-row gap-2 items-center">
{#if $enterpriseLicense && !newFlow}
<Awareness />
{/if}
<div>
{#if moreItems?.length > 0}
<Dropdown items={moreItems} />
{/if}
</div>
{#if customUi?.topBar?.tutorials != false}
<FlowBuilderTutorials
on:reload={() => {
renderCount += 1
}}
/>
{/if}
{#if customUi?.topBar?.diff != false}
<Button
color="light"
variant="border"
size="xs"
on:click={async () => {
if (!savedFlow) {
return
}
await syncWithDeployed()
const currentDraftTriggers = structuredClone(
triggersState.getDraftTriggersSnapshot()
)
diffDrawer?.openDrawer()
diffDrawer?.setDiff({
mode: 'normal',
deployed: deployedValue ?? savedFlow,
draft: savedFlow?.draft,
current: { ...$flowStore, path: $pathStore, draft_triggers: currentDraftTriggers }
})
}}
disabled={!savedFlow}
>
<div class="flex flex-row gap-2 items-center">
<DiffIcon size={14} />
Diff
</div>
</Button>
{/if}
{#if !disableAi && customUi?.topBar?.aiBuilder != false}
<FlowCopilotStatus
{copilotLoading}
bind:copilotStatus
{genFlow}
{finishCopilotFlowBuilder}
{abortController}
/>
{/if}
<FlowPreviewButtons
on:openTriggers={(e) => {
select('triggers')
handleSelectTriggerFromKind(triggersState, triggersCount, initialPath, e.detail.kind)
captureOn.set(true)
showCaptureHint.set(true)
}}
bind:this={flowPreviewButtons}
{loading}
/>
<Button
loading={loadingDraft}
size="xs"
startIcon={{ icon: Save }}
on:click={() => saveDraft()}
disabled={(!newFlow && !savedFlow) || loading}
shortCut={{
key: 'S'
}}
>
Draft
</Button>
<DeployButton
on:save={async ({ detail }) => await handleSaveFlow(detail)}
{loading}
{loadingSave}
{newFlow}
{dropdownItems}
/>
</div>
</div>
<!-- metadata -->
{#if $flowStateStore}
<FlowEditor
{disabledFlowInputs}
disableAi={disableAi || customUi?.stepInputs?.ai == false}
disableSettings={customUi?.settingsPanel === false}
{loading}
on:reload={() => {
renderCount += 1
}}
{newFlow}
on:applyArgs={(ev) => {
if (ev.detail.kind === 'preprocessor') {
$testStepStore['preprocessor'] = ev.detail.args ?? {}
$selectedIdStore = 'preprocessor'
}
}}
on:testWithArgs={(e) => {
$previewArgsStore = JSON.parse(JSON.stringify(e.detail))
flowPreviewButtons?.openPreview(true)
}}
{savedFlow}
onDeployTrigger={handleDeployTrigger}
/>
{:else}
<CenteredPage>Loading...</CenteredPage>
{/if}
</div>
{:else}
Flow Builder not available to operators
{/if}
{/key}
<FlowTutorials
bind:this={flowTutorials}
on:reload={() => {
renderCount += 1
}}
/>