mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-29 08:12:56 +00:00
* feat(frontend): adapt AI-chat/sessions drafts to DB-backed model PR #9351 dropped UserDraft's localStorage layer; the chat adapter's synchronous save->read-back threw "Could not read written draft". The adapter now treats the backend as source of truth (in-tab cell used opportunistically for live-preview coherence) with conflict-on-save, and read tools fall back to the backend. Collapses the six writeXDraft functions onto one generic writeDraft + typed per-kind WriteSpec constants. Terminology: "local draft" -> "draft" (drafts are server-side). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(frontend): autosave indicator + draft-only diff guard in session editors Thread an explicit (workspace, path) autosave target to the cloud AutosaveIndicator in the Script/Flow/RawApp session previews so it watches the same key saves land on (it previously watched an empty path and never animated). Disable the Diff button with a hint for draft-only (no_deployed) items consistently across the three editors. Adjust the script topbar compact breakpoint/layout so the cloud icon is part of the bar, and stop splitpanes over-constraining session panes on reload. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): session draft diff viewer for schedule/resource/variable Canonicalize both sides of the draft diff onto one field set and strip runtime-only fields so rows aren't spuriously marked all-changed; mask secret values. Map draft itemKinds to deploy-style kinds so the DiffRow shows the correct icon/label. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): uniform diff-viewer row height regardless of summary Diff-viewer leaf rows (WorkspaceItemRow) drew two lines when an item had a summary and one line otherwise, giving unequal heights. Add an opt-in `uniformHeight` prop that gives the text wrapper a shared min-height and vertically centers the one-line case; enable it only from the diff viewer. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(frontend): address review nits on the drafts diff/guard changes - Reuse the exported TRIGGER_RUNTIME_IGNORE from utils_deployable instead of a verbatim copy, so the runtime-field ignore list has one source of truth. - Drop the now-redundant `(savedApp as any)` cast in RawAppEditorHeader; the prop type already carries `no_deployed`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): add description parameter to the write_flow chat tool write_flow had no way to set a flow's top-level description (the sibling of summary in OpenFlow); patch_flow_json only edits the compact value, so the field was unreachable from the AI chat. Thread an optional description end-to-end: tool schema -> persisted draft -> read-back -> deploy body. Structural patches (patch_flow_json/set_flow_module_code) pass no description, so a previously-set description is preserved. Adds a deployRequests regression test asserting a draft description reaches the deploy body, overriding the deployed one. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): round-trip top-level fields in session preview draft sync The session preview's two-way draft sync dedups on a per-kind signature and mirrors fields between the editor store and the shared UserDraft cell. Both omitted fields the chat can set, so with the preview open a change to only that field was swallowed (identical signature) and then clobbered by the editor's outbound save: - flow: the signature and applyDraftToStore ignored top-level `description`. - script: the signature keyed on `content` alone, dropping `summary`/`language`. Add the missing fields to flowDraftSig and the script codec signature, and copy `description` in the flow codec's applyDraftToStore (mirroring `summary`). Raw-app already stringifies the whole draft, so it was unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): deploy draft-only flow from the session preview Deploying a draft-only flow (a draft with no deployed row) from the session preview hit two gaps the full-page flow editor already handled: - create vs update: newFlow keyed on `!savedFlow.val`, but a draft-only flow has a synthesized savedFlow (no_deployed=true), so deploy took updateFlow against the draft path and 404'd "Flow not found". Key it on no_deployed too. - friendly name: a brand-new flow is stored under a `draft_<uuid>` path with its intended name in `draft_path`. Seed the builder's initialPath from `draft_path` (as the full-page editor does) so the Path widget and deploy use the friendly name instead of creating a flow named draft_<uuid>. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): deploy draft-only raw app from the session preview Same create-vs-update bug as the flow session preview: newApp keyed on `!savedRawApp.val`, but a draft-only app has a truthy synthesized savedApp (getAppByPath with rawApp:true resolves to the draft kind instead of 404ing, carrying no_deployed=true), so deploy took updateApp against a path with no deployed row and 404'd "not found". Key newApp on no_deployed too so a never-deployed app deploys via createApp. More reachable than the flow case: it hit any never-deployed app, including chat-created ones at friendly paths. Keying newApp on no_deployed also exposed that newEditedPath (the breadcrumb path AND the createApp target) used newApp to mean "brand-new, generate a random name". A draft-only app is newApp=true but already has a real path (empty newPath at init, but appPath is set), so it showed and would deploy a random `*_app` name. Prefer the real appPath before the random fallback, so only a genuinely new app (appPath === '') still gets a generated suggestion; the full-page editor is unaffected (it always sets newPath). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): don't re-save a draft after deploying from the session preview Deploying from a session preview reloaded the editor (expected) but then immediately POSTed a fresh draft. The full-page editor guards deploy with discardDraftAfterDeploy (stopSync + arm-restart-on-first-interaction), but the shared editor header skips that in a session pane (inSessionPane) and routes post-deploy cleanup through sessionRuntime.syncPreviewWithDeployed, which did discard + reload without the stopSync guard. UserDraft.discard keeps the cell entry, so the reload's UserDraft.save fired the cell's reactive effect and re-POSTed the just-deployed value as a draft. Wrap the discard + reload in the same UserDraft.stopSync + armRestartOnFirst- Interaction bracket. One place fixes all three kinds (script/flow/raw_app), since they all funnel through syncPreviewWithDeployed; autosave resumes on the next genuine edit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(frontend): address review findings on the session-preview drafts work - Type `no_deployed` via the GetXByPathResponse/UserDraftOverlay types instead of `(result as any)`/`(saved as any)` casts at the sites this branch added (sessionRuntime, ScriptBuilder, FlowBuilder, + widened the script/flow builder prop types). Pre-existing trigger/variable/resource-editor casts left untouched. - Drop a history-narrating comment parenthetical per the AGENTS.md comment policy (RawAppEditorView). - Add a unit test covering persistGlobalDraft's conflict-on-save / override path (conflict-capable updateDraft mock; inert for existing tests). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): keep the friendly generated path for a brand-new raw app The earlier draft-only newApp fix made newEditedPath prefer `appPath` before the random suggestion, but a brand-new app is parked at the storage placeholder `u/{user}/draft_{uuid}` (the /apps_raw/add redirect target), so it surfaced that uuid instead of a friendly `<adjective>_app` suggestion. Reject a `draft_` placeholder segment when choosing the path: a real named/draft-only path is still kept, a placeholder falls through to the generated suggestion. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): show the Diff-button tooltip when it's disabled A disabled <button> fires no pointer events and browsers suppress its native title, so the "deploy once to compare" explanation never showed on hover for a draft-only item's Diff button. Wrap the button in a titled element and set pointer-events-none on the button when disabled, so the hover reaches the wrapper. Applied in ScriptBuilder, FlowBuilder, and RawAppEditorHeader (covers both the full-page editors and the session preview). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): surface draft save failures and conflicts in the AI chat tools Addresses Codex + Pi review findings on PR #9601 (three P1s, all in the DB-backed draft adapter reporting success when the write didn't land): - persistGlobalDraft reported {status:'saved'} even when UserDraftDbSyncer.save failed (it records network/5xx into a failure map instead of throwing). Check getState().state==='failed' after the save and return a new 'error' status; finishDraftWrite now emits success:false with a retry hint. - saveGlobalAppDraft dropped the conflict/error status (returned only the item), so write_app_file/patch_app_file/write_app_runnable reported every stale or failed write as saved. It now returns the full DraftPersistResult, and the six app write tools route through a shared finishAppDraftWrite helper. - fetchBackendDraftValue's catch{} swallowed non-404 errors (403/500/network), collapsing them to "no draft" so the write merged from the deployed item and lost in-progress draft edits. Narrow the catch to status===404; propagate the rest. Adds unit coverage: save-failure -> 'error', non-404 read -> propagates, raw-app stale write -> 'conflict'. 71/71 pass, check:fast + full build clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): surface failed draft deletes + strip tool-only override from schedule drafts Addresses Codex's second-round review on PR #9601: - [P1] deleteGlobalDraft reported success even when the server delete failed or conflicted (UserDraftDbSyncer.save records failure state instead of throwing) — so discard_local_draft / deploy_workspace_item / delete_workspace_item / the /global_drafts delete could report a draft removed while the DB still had it. Check getState().state and getConflict() after the awaited null save and throw, mirroring the write-path guard. - [P2] writeScheduleDraft persisted the tool-only `override` conflict flag into the schedule draft value (mergeDraftConfig cloned every arg field). Strip `override` in SCHEDULE_SPEC.buildDraft before merging. Tests: failed server delete -> throws; schedule draft no longer contains `override`. 73/73 pass, check:fast + full build clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): /global_drafts "Clear all" deletes persisted drafts, not just cells Codex review nit (P2): the dev-only global-drafts inspector's "Clear all" called clearGlobalDrafts(), which only iterates in-tab UserDraft cells — any persisted backend draft row not currently mounted as a cell survived, so the list re-showed it after refresh. Iterate the listed drafts and delete each via the backend-aware deleteGlobalDraft() (continue past per-row failures), matching the per-row delete, then clear local cells + ephemeral secrets. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1271 lines
38 KiB
Svelte
1271 lines
38 KiB
Svelte
<script lang="ts">
|
|
import {
|
|
FlowService,
|
|
type Flow,
|
|
type PathScript,
|
|
type OpenFlow,
|
|
type InputTransform,
|
|
type TriggersCount,
|
|
CaptureService,
|
|
type Job
|
|
} from '$lib/gen'
|
|
import { initHistory, redo, undo } from '$lib/history.svelte'
|
|
import { enterpriseLicense, userStore, workspaceStore, usedTriggerKinds } from '$lib/stores'
|
|
import {
|
|
generateRandomString,
|
|
orderedJsonStringify,
|
|
readFieldsRecursively,
|
|
replaceFalseWithUndefined,
|
|
isMac,
|
|
userPathPrefix,
|
|
type Item,
|
|
type StateStore,
|
|
type Value
|
|
} from '$lib/utils'
|
|
import { sendUserToast } from '$lib/toast'
|
|
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
|
|
import { Drawer } from '$lib/components/common'
|
|
import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte'
|
|
import AIChangesWarningModal from '$lib/components/copilot/chat/flow/AIChangesWarningModal.svelte'
|
|
|
|
import { createRawSnippet, setContext, untrack } from 'svelte'
|
|
import { writable } from 'svelte/store'
|
|
import CenteredPage from './CenteredPage.svelte'
|
|
import { Button } from './common'
|
|
import FlowEditor from './flows/FlowEditor.svelte'
|
|
import ScriptEditorDrawer from './flows/content/ScriptEditorDrawer.svelte'
|
|
import FlowEditorDrawer from './flows/content/FlowEditorDrawer.svelte'
|
|
import { dfs as dfsApply } from './flows/dfs'
|
|
import FlowImportExportMenu from './flows/header/FlowImportExportMenu.svelte'
|
|
import FlowPreviewButtons from './flows/header/FlowPreviewButtons.svelte'
|
|
import type { FlowEditorContext, FlowInput, FlowInputEditorState } from './flows/types'
|
|
import { SelectionManager } from './graph/selectionUtils.svelte'
|
|
import { NoteEditor } from './graph/noteEditor.svelte'
|
|
import { setNoteEditorContext } from './graph/noteEditor.svelte'
|
|
import { GroupEditor, setGroupEditorContext } from './graph/groupEditor.svelte'
|
|
import { cleanFlow } from './flows/utils.svelte'
|
|
import {
|
|
DiffIcon,
|
|
HistoryIcon,
|
|
FileJson,
|
|
Settings,
|
|
Undo,
|
|
Redo,
|
|
BookOpen,
|
|
Circle,
|
|
CheckCircle,
|
|
RefreshCw,
|
|
CheckCheck,
|
|
Disc
|
|
} from 'lucide-svelte'
|
|
import Awareness from './Awareness.svelte'
|
|
import { getAllModules } from './flows/flowExplorer'
|
|
import { type FlowCopilotContext } from './copilot/flow'
|
|
import { loadFlowModuleState } from './flows/flowStateUtils.svelte'
|
|
import Dropdown from '$lib/components/DropdownV2.svelte'
|
|
import FlowTutorials from './FlowTutorials.svelte'
|
|
import FlowHistory from './flows/FlowHistory.svelte'
|
|
import { resetAllTodos, skipAllTodos } from '$lib/tutorialUtils'
|
|
import { tutorialsToDo } from '$lib/stores'
|
|
import { getTutorialIndex } from '$lib/tutorials/config'
|
|
import EditorHeader from './EditorHeader.svelte'
|
|
import AutosaveIndicator from './AutosaveIndicator.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 { invalidateWorkspacePaths } from './PathNameAutocomplete.svelte'
|
|
import type { Trigger } from './triggers/utils'
|
|
import { deployTriggers, handleSelectTriggerFromKind } from './triggers/utils'
|
|
import DraftTriggersConfirmationModal from './common/confirmationModal/DraftTriggersConfirmationModal.svelte'
|
|
import { Triggers } from './triggers/triggers.svelte'
|
|
import { StepsInputArgs } from './flows/stepsInputArgs.svelte'
|
|
import { aiChatManager } from './copilot/chat/AIChatManager.svelte'
|
|
import type { GraphModuleState } from './graph'
|
|
import { validateRetryConfig } from '$lib/utils'
|
|
import {
|
|
setStepHistoryLoaderContext,
|
|
StepHistoryLoader,
|
|
type stepState
|
|
} from './stepHistoryLoader.svelte'
|
|
import type { FlowBuilderProps } from './flow_builder'
|
|
import { ModulesTestStates } from './modulesTest.svelte'
|
|
import FlowAssetsHandler, { initFlowGraphAssetsCtx } from './flows/FlowAssetsHandler.svelte'
|
|
import { isRuleActive } from '$lib/workspaceProtectionRules.svelte'
|
|
import { buildForkEditUrl } from '$lib/utils/editInFork'
|
|
import { isCloudHosted } from '$lib/cloud'
|
|
import { UserDraft } from '$lib/userDraft.svelte'
|
|
|
|
let {
|
|
initialPath = $bindable(''),
|
|
pathStoreInit = undefined,
|
|
newFlow,
|
|
selectedId,
|
|
initialArgs = {},
|
|
loading = false,
|
|
flowStore,
|
|
flowStateStore,
|
|
savedFlow = $bindable(undefined),
|
|
diffDrawer = undefined,
|
|
customUi = {},
|
|
disableAi = false,
|
|
disabledFlowInputs = false,
|
|
savedPrimarySchedule = undefined,
|
|
version = undefined,
|
|
draftTriggersFromUrl = undefined,
|
|
selectedTriggerIndexFromUrl = undefined,
|
|
children,
|
|
loadedFromHistoryFromUrl,
|
|
noInitial = false,
|
|
liveEditorDraftStoragePath = undefined,
|
|
autosaveWorkspace = undefined,
|
|
autosavePath = undefined,
|
|
onDeploy,
|
|
onDeployError,
|
|
onDetails,
|
|
onHistoryRestore,
|
|
onNavigate,
|
|
onResetToDeployed,
|
|
loadedFromDraft = false,
|
|
othersDraftsCount = 0,
|
|
onOpenOthersDrafts,
|
|
onTestJob
|
|
}: FlowBuilderProps = $props()
|
|
|
|
// Key the AutosaveIndicator watches. Falls back to this component's own
|
|
// draft key, so the full-page editor is unchanged; the sessions preview
|
|
// overrides both to the (forked) workspace + path its autosave saves under.
|
|
const indicatorWorkspace = $derived(autosaveWorkspace ?? $workspaceStore)
|
|
const indicatorPath = $derived(autosavePath ?? liveEditorDraftStoragePath)
|
|
|
|
let initialPathStore = writable(initialPath)
|
|
|
|
// For preserve_on_behalf_of feature
|
|
let preserveOnBehalfOf = writable(false)
|
|
let savedOnBehalfOfEmail = writable<string | undefined>(savedFlow?.on_behalf_of_email)
|
|
|
|
// Keep savedOnBehalfOfEmail in sync when savedFlow is loaded asynchronously
|
|
$effect(() => {
|
|
if (savedFlow?.on_behalf_of_email !== undefined) {
|
|
savedOnBehalfOfEmail.set(savedFlow.on_behalf_of_email)
|
|
}
|
|
})
|
|
|
|
// used for new flows for captures
|
|
let fakeInitialPath = userPathPrefix($userStore?.username) + generateRandomString(12)
|
|
|
|
// Used by multiplayer deploy collision warning
|
|
let deployedValue: Value | undefined = $state(undefined) // Value to diff against
|
|
let deployedBy: string | undefined = $state(undefined) // Author
|
|
let confirmCallback: () => void = $state(() => {}) // What happens when user clicks `override` in warning
|
|
let open: boolean = $state(false) // Is confirmation modal open
|
|
|
|
// Draft triggers confirmation modal
|
|
let draftTriggersModalOpen = $state(false)
|
|
|
|
// Top-bar responsive collapse. Measured via bind:clientWidth — we can't
|
|
// rely on viewport `md:` because the editor lives inside other panes
|
|
// (session pane, drawer, etc.) where the viewport stays wide.
|
|
let topbarWidth = $state(0)
|
|
const compactTopbar = $derived(topbarWidth > 0 && topbarWidth < 720)
|
|
let confirmDeploymentCallback: (triggersToDeploy: Trigger[]) => void = () => {}
|
|
|
|
// AI changes warning modal
|
|
let aiChangesWarningOpen = $state(false)
|
|
let aiChangesConfirmCallback = $state<() => void>(() => {})
|
|
|
|
// Flow preview
|
|
let flowPreviewButtons: FlowPreviewButtons | undefined = $state()
|
|
const flowPreviewContent = $derived(flowPreviewButtons?.getFlowPreviewContent())
|
|
const job: Job | undefined = $derived(flowPreviewContent?.getJob())
|
|
let showJobStatus = $state(false)
|
|
|
|
async function handleDraftTriggersConfirmed(event: CustomEvent<{ selectedTriggers: Trigger[] }>) {
|
|
const { selectedTriggers } = event.detail
|
|
// Continue with saving the flow
|
|
draftTriggersModalOpen = false
|
|
confirmDeploymentCallback(selectedTriggers)
|
|
}
|
|
|
|
function hasAIChanges(): boolean {
|
|
return aiChatManager.flowAiChatHelpers?.hasPendingChanges() ?? false
|
|
}
|
|
|
|
function withAIChangesWarning(callback: () => void) {
|
|
if (hasAIChanges()) {
|
|
aiChangesConfirmCallback = () => {
|
|
aiChatManager.flowAiChatHelpers?.rejectAllModuleActions()
|
|
callback()
|
|
}
|
|
aiChangesWarningOpen = true
|
|
} else {
|
|
callback()
|
|
}
|
|
}
|
|
|
|
export function getInitialAndModifiedValues(): SavedAndModifiedValue {
|
|
return {
|
|
savedValue: savedFlow,
|
|
modifiedValue: {
|
|
...flowStore.val,
|
|
// `$pathStore` is the live-edited path (the pen popover binds it).
|
|
// `flowStore.val.path` doesn't track those edits, so without this the
|
|
// rename wouldn't show up in the diff and the unsaved-changes warning
|
|
// wouldn't fire when leaving with a pending rename.
|
|
path: $pathStore,
|
|
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 primaryScheduleStore = writable<ScheduleTrigger | undefined | false>(
|
|
untrack(() => 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 = $state(false)
|
|
|
|
// Ctrl/Cmd+S forces an immediate save of whatever the page-level
|
|
// autosave has pending. Unlike ScriptBuilder we don't have a direct
|
|
// Monaco ref to flush — any focused module Monaco's pending text is
|
|
// constrained by the editor's own ~1s max-wait cap, so the flush
|
|
// here picks up whatever's already in `pendingSaveOpts`. Worst case
|
|
// the user's very last keystroke (<1s ago) isn't in this POST and
|
|
// follows in the next autosave round.
|
|
//
|
|
// No toast — the AutosaveIndicator narrates the flush (Saving... →
|
|
// Saved / Save failed). A toast here would also lie on network
|
|
// failure: `flush` never rejects (postSave catches and routes errors
|
|
// to the failures map), so the success branch fired regardless.
|
|
export async function saveDraft(): Promise<void> {
|
|
if (!$workspaceStore || !liveEditorDraftStoragePath) return
|
|
await UserDraftDbSyncer.flush({
|
|
workspace: $workspaceStore,
|
|
itemKind: 'flow',
|
|
path: liveEditorDraftStoragePath
|
|
})
|
|
}
|
|
|
|
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) {
|
|
withAIChangesWarning(async () => {
|
|
await handleSaveFlowInternal(deploymentMsg)
|
|
})
|
|
}
|
|
|
|
async function handleSaveFlowInternal(deploymentMsg?: string) {
|
|
await compareVersions()
|
|
if (onLatest || initialPath == '' || newFlow) {
|
|
// Handle directly
|
|
await saveFlow(deploymentMsg)
|
|
} else {
|
|
// We need it for diff
|
|
await syncWithDeployed()
|
|
|
|
if (
|
|
deployedValue &&
|
|
flowStore.val &&
|
|
orderedJsonStringify(deployedValue) ===
|
|
orderedJsonStringify(replaceFalseWithUndefined({ ...flowStore.val, 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 = cleanFlow(flowStore.val)
|
|
|
|
if (flow.value?.modules) {
|
|
const validationErrors: string[] = []
|
|
dfsApply(flow.value.modules, (module) => {
|
|
const error = validateRetryConfig(module.retry)
|
|
if (error) {
|
|
validationErrors.push(`Step '${module.id}': ${error}`)
|
|
}
|
|
})
|
|
|
|
if (flow.value.failure_module) {
|
|
// add validation logic here for failure module
|
|
}
|
|
|
|
if (flow.value.preprocessor_module) {
|
|
// add validation logic here for preprocessor module
|
|
}
|
|
|
|
if (validationErrors.length > 0) {
|
|
throw new Error(validationErrors.join('\n'))
|
|
}
|
|
}
|
|
// console.log('flow', computeUnlockedSteps(flow)) // del
|
|
// loadingSave = false // del
|
|
// return
|
|
|
|
if (newFlow) {
|
|
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,
|
|
preserve_on_behalf_of: $preserveOnBehalfOf || undefined,
|
|
deployment_message: deploymentMsg || undefined,
|
|
labels: (flow as any).labels
|
|
}
|
|
})
|
|
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 {
|
|
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,
|
|
preserve_on_behalf_of: $preserveOnBehalfOf || undefined,
|
|
deployment_message: deploymentMsg || undefined,
|
|
labels: (flow as any).labels
|
|
}
|
|
})
|
|
}
|
|
|
|
// New/updated path now exists server-side — drop the autocomplete
|
|
// cache so it shows up immediately instead of after the 60s TTL.
|
|
invalidateWorkspacePaths($workspaceStore!)
|
|
|
|
const { draft_triggers: _, ...newSavedFlow } = flowStore.val as OpenFlow & {
|
|
draft_triggers: Trigger[]
|
|
}
|
|
savedFlow = {
|
|
...structuredClone($state.snapshot(newSavedFlow)),
|
|
path: $pathStore
|
|
} as Flow
|
|
setDraftTriggers([])
|
|
loadingSave = false
|
|
onDeploy?.({ path: $pathStore })
|
|
} catch (err) {
|
|
onDeployError?.({ error: err })
|
|
// this is so we can use the flow builder outside of sveltekit
|
|
sendUserToast(`The flow could not be saved: ${err.body ?? err}`, true)
|
|
loadingSave = false
|
|
}
|
|
}
|
|
|
|
const selectionManager = new SelectionManager()
|
|
const selectedIdStore = $derived(selectionManager.getSelectedId())
|
|
// Initialize with selected id if provided
|
|
if (untrack(() => selectedId)) {
|
|
selectionManager.selectId(untrack(() => selectedId) ?? '')
|
|
} else {
|
|
selectionManager.selectId('settings-metadata')
|
|
}
|
|
|
|
export function getSelectedId() {
|
|
return selectedIdStore
|
|
}
|
|
|
|
const previewArgsStore = $state({ val: untrack(() => initialArgs) })
|
|
const scriptEditorDrawer = writable<ScriptEditorDrawer | undefined>(undefined)
|
|
const flowEditorDrawer = writable<FlowEditorDrawer | undefined>(undefined)
|
|
const history = initHistory(untrack(() => flowStore).val)
|
|
const pathStore = writable<string>(untrack(() => pathStoreInit) ?? initialPath)
|
|
|
|
$effect(() => {
|
|
if (liveEditorDraftStoragePath === undefined || !$workspaceStore) return
|
|
const workspace = $workspaceStore
|
|
UserDraft.setLiveEditorDraft({
|
|
workspace,
|
|
itemKind: 'flow',
|
|
storagePath: liveEditorDraftStoragePath,
|
|
effectivePath: $pathStore
|
|
})
|
|
return () =>
|
|
UserDraft.clearLiveEditorDraft('flow', {
|
|
workspace,
|
|
storagePath: liveEditorDraftStoragePath
|
|
})
|
|
})
|
|
|
|
const captureOn = writable<boolean>(false)
|
|
const showCaptureHint = writable<boolean | undefined>(undefined)
|
|
const flowInputEditorStateStore = writable<FlowInputEditorState>({
|
|
selectedTab: undefined,
|
|
editPanelSize: 0,
|
|
payloadData: undefined
|
|
})
|
|
|
|
const stepsInputArgs = new StepsInputArgs()
|
|
|
|
function select(selectedId: string) {
|
|
selectionManager.selectId(selectedId)
|
|
}
|
|
|
|
let insertButtonOpen = writable<boolean>(false)
|
|
let modulesTestStates = new ModulesTestStates()
|
|
let outputPickerOpenFns: Record<string, () => void> = $state({})
|
|
let flowEditor: FlowEditor | undefined = $state(undefined)
|
|
|
|
setContext<FlowEditorContext>('FlowEditorContext', {
|
|
selectionManager,
|
|
currentEditor: writable(undefined),
|
|
previewArgs: previewArgsStore,
|
|
scriptEditorDrawer,
|
|
flowEditorDrawer,
|
|
history,
|
|
flowStateStore: untrack(() => flowStateStore),
|
|
flowStore: untrack(() => flowStore),
|
|
pathStore,
|
|
stepsInputArgs,
|
|
saveDraft,
|
|
initialPathStore,
|
|
fakeInitialPath,
|
|
flowInputsStore: writable<FlowInput>({}),
|
|
customUi: untrack(() => customUi),
|
|
insertButtonOpen,
|
|
executionCount: writable(0),
|
|
flowInputEditorState: flowInputEditorStateStore,
|
|
modulesTestStates,
|
|
outputPickerOpenFns,
|
|
preserveOnBehalfOf,
|
|
savedOnBehalfOfEmail
|
|
})
|
|
|
|
// Set up NoteEditor context for note editing capabilities
|
|
const noteEditor = new NoteEditor(
|
|
untrack(() => flowStore),
|
|
() => {
|
|
// Enable notes display when a note is created
|
|
flowEditor?.enableNotes?.()
|
|
}
|
|
)
|
|
setNoteEditorContext(noteEditor)
|
|
|
|
// Set up GroupEditor context for group editing capabilities
|
|
const groupEditor = new GroupEditor(flowStore)
|
|
let canCreateGroup = $state({ val: false })
|
|
setGroupEditorContext(groupEditor, canCreateGroup)
|
|
|
|
setContext(
|
|
'FlowGraphAssetContext',
|
|
initFlowGraphAssetsCtx({ getModules: () => flowStore.val.value.modules })
|
|
)
|
|
|
|
// Add triggers context store
|
|
const triggersState = $state(
|
|
new Triggers(
|
|
[
|
|
{ type: 'webhook', path: '', isDraft: false },
|
|
{ type: 'default_email', path: '', isDraft: false },
|
|
...(untrack(() => draftTriggersFromUrl) ?? [])
|
|
],
|
|
untrack(() => selectedTriggerIndexFromUrl)
|
|
)
|
|
)
|
|
|
|
setContext<TriggerContext>('TriggerContext', {
|
|
triggersCount,
|
|
simplifiedPoll,
|
|
showCaptureHint,
|
|
triggersState
|
|
})
|
|
|
|
export async function loadTriggers() {
|
|
if (initialPath == '') return
|
|
$triggersCount = await FlowService.getTriggersCountOfFlow({
|
|
workspace: $workspaceStore!,
|
|
path: initialPath
|
|
})
|
|
|
|
// Initialize triggers using utility function
|
|
await triggersState.fetchTriggers(
|
|
triggersCount,
|
|
$workspaceStore,
|
|
initialPath,
|
|
true,
|
|
$primaryScheduleStore,
|
|
$userStore
|
|
)
|
|
}
|
|
|
|
function handleUndo() {
|
|
const currentModules = flowStore.val?.value?.modules
|
|
flowStore.val = undo(history, flowStore.val)
|
|
const newModules = flowStore.val?.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.val[mod.id] = state))
|
|
} catch (e) {
|
|
console.error('Error loading state for restored node', e)
|
|
}
|
|
}
|
|
}
|
|
selectionManager.selectId('Input')
|
|
}
|
|
|
|
function handleRedo() {
|
|
flowStore.val = redo(history)
|
|
}
|
|
|
|
let flowBuilderRoot: HTMLDivElement | undefined = $state()
|
|
|
|
function onKeyDown(event: KeyboardEvent) {
|
|
// Defer to anything that has explicitly grabbed focus — menus, modals,
|
|
// drawers etc. live outside the flow root. Flow nodes aren't focusable,
|
|
// so the unfocused default (activeElement === body) means "flow is the
|
|
// canvas" and we should react.
|
|
const active = document.activeElement
|
|
if (active && active !== document.body && !flowBuilderRoot?.contains(active)) {
|
|
return
|
|
}
|
|
|
|
let classes = event.target?.['className']
|
|
if (
|
|
(typeof classes === 'string' && classes.includes('inputarea')) ||
|
|
['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName!)
|
|
) {
|
|
return
|
|
}
|
|
|
|
// Only lowercase single-char keys — named keys like `ArrowDown` must
|
|
// stay PascalCase to match their switch cases.
|
|
switch (event.key.length === 1 ? event.key.toLowerCase() : event.key) {
|
|
case 'z':
|
|
if (event.ctrlKey || event.metaKey) {
|
|
if (event.shiftKey) handleRedo()
|
|
else handleUndo()
|
|
event.preventDefault()
|
|
}
|
|
break
|
|
case 's':
|
|
if (event.ctrlKey || event.metaKey) {
|
|
saveDraft()
|
|
event.preventDefault()
|
|
}
|
|
break
|
|
case 'ArrowDown': {
|
|
if (!$insertButtonOpen && !flowPreviewButtons?.getPreviewOpen()) {
|
|
let ids = generateIds()
|
|
let idx = ids.indexOf(selectedIdStore!)
|
|
if (idx > -1 && idx < ids.length - 1) {
|
|
selectionManager.selectId(ids[idx + 1])
|
|
event.preventDefault()
|
|
}
|
|
}
|
|
break
|
|
}
|
|
case 'ArrowUp': {
|
|
if (!$insertButtonOpen && !flowPreviewButtons?.getPreviewOpen()) {
|
|
let ids = generateIds()
|
|
let idx = ids.indexOf(selectedIdStore!)
|
|
if (idx > 0 && idx < ids.length) {
|
|
selectionManager.selectId(ids[idx - 1])
|
|
event.preventDefault()
|
|
}
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
function generateIds() {
|
|
return [
|
|
'settings-metadata',
|
|
'constants',
|
|
'preprocessor',
|
|
...dfsApply(flowStore.val.value.modules, (module) => module.id)
|
|
]
|
|
}
|
|
|
|
const dropdownItems: Array<{
|
|
label: string
|
|
onClick: () => void
|
|
}> = []
|
|
|
|
if (untrack(() => customUi).topBar?.extraDeployOptions != false) {
|
|
if (!newFlow) {
|
|
dropdownItems.push({
|
|
label: 'Exit & see details',
|
|
// Use the deployed path, not the live `$pathStore` — the latter
|
|
// reflects local rename edits that haven't been deployed yet,
|
|
// which would land the user on a 404 details page.
|
|
onClick: () => onDetails?.({ path: initialPath })
|
|
})
|
|
}
|
|
|
|
if (!untrack(() => newFlow)) {
|
|
dropdownItems.push({
|
|
label: 'Fork',
|
|
onClick: () => window.open(`/flows/add?template=${initialPath}`)
|
|
})
|
|
}
|
|
|
|
if (!untrack(() => newFlow) && !isCloudHosted() && !isRuleActive('DisableWorkspaceForking')) {
|
|
dropdownItems.push({
|
|
label: 'Edit in workspace fork',
|
|
onClick: () => window.open(buildForkEditUrl('flow', initialPath))
|
|
})
|
|
}
|
|
}
|
|
|
|
async function openDiffDrawer() {
|
|
if (!savedFlow) return
|
|
await syncWithDeployed()
|
|
const currentDraftTriggers = structuredClone(triggersState.getDraftTriggersSnapshot())
|
|
diffDrawer?.openDrawer()
|
|
const currentFlow = flowStore.val
|
|
diffDrawer?.setDiff({
|
|
mode: 'normal',
|
|
deployed: deployedValue ?? savedFlow,
|
|
current: {
|
|
...currentFlow,
|
|
path: $pathStore,
|
|
draft_triggers: currentDraftTriggers
|
|
}
|
|
})
|
|
}
|
|
|
|
let flowCopilotContext: FlowCopilotContext = $state({
|
|
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)
|
|
|
|
let renderCount = $state(0)
|
|
let flowTutorials: FlowTutorials | undefined = $state(undefined)
|
|
|
|
let jsonViewerDrawer: Drawer | undefined = $state(undefined)
|
|
let yamlEditorDrawer: Drawer | undefined = $state(undefined)
|
|
let flowHistory: FlowHistory | undefined = $state(undefined)
|
|
|
|
export function triggerTutorial() {
|
|
const urlParams = new URLSearchParams(window.location.search)
|
|
const tutorial = urlParams.get('tutorial')
|
|
|
|
if (tutorial) {
|
|
flowTutorials?.runTutorialById(tutorial)
|
|
}
|
|
}
|
|
|
|
let baseMenuItems: Item[] = $state([])
|
|
|
|
const mod = isMac() ? '⌘' : 'Ctrl+'
|
|
|
|
function getMoreItems(): Item[] {
|
|
return [
|
|
...baseMenuItems,
|
|
{
|
|
displayName: 'Undo',
|
|
icon: Undo,
|
|
action: () => handleUndo(),
|
|
disabled: $history.index === 0,
|
|
shortcut: `${mod}Z`,
|
|
separatorTop: baseMenuItems.length > 0
|
|
},
|
|
{
|
|
displayName: 'Redo',
|
|
icon: Redo,
|
|
action: () => handleRedo(),
|
|
disabled: $history.index === $history.history.length - 1,
|
|
shortcut: `${mod}⇧Z`
|
|
},
|
|
{
|
|
displayName: 'Tutorials',
|
|
icon: BookOpen,
|
|
separatorTop: true,
|
|
extra: (() => {
|
|
const remaining = [
|
|
getTutorialIndex('flow-live-tutorial'),
|
|
getTutorialIndex('troubleshoot-flow')
|
|
].filter((i) => $tutorialsToDo.includes(i)).length
|
|
return remaining > 0
|
|
? createRawSnippet(() => ({
|
|
render: () =>
|
|
`<span class="ml-auto inline-flex items-center justify-center w-4 h-4 text-[10px] font-medium text-white rounded-full bg-surface-accent-primary">${remaining}</span>`
|
|
}))
|
|
: undefined
|
|
})(),
|
|
submenuItems: [
|
|
{
|
|
displayName: 'Build a flow',
|
|
action: () => flowTutorials?.runTutorialById('flow-live-tutorial'),
|
|
icon: $tutorialsToDo.includes(getTutorialIndex('flow-live-tutorial'))
|
|
? Circle
|
|
: CheckCircle,
|
|
iconColor: $tutorialsToDo.includes(getTutorialIndex('flow-live-tutorial'))
|
|
? undefined
|
|
: 'green'
|
|
},
|
|
{
|
|
displayName: 'Fix a broken flow',
|
|
action: () => flowTutorials?.runTutorialById('troubleshoot-flow'),
|
|
icon: $tutorialsToDo.includes(getTutorialIndex('troubleshoot-flow'))
|
|
? Circle
|
|
: CheckCircle,
|
|
iconColor: $tutorialsToDo.includes(getTutorialIndex('troubleshoot-flow'))
|
|
? undefined
|
|
: 'green'
|
|
},
|
|
{
|
|
displayName: 'Reset tutorials',
|
|
action: () => resetAllTodos(),
|
|
icon: RefreshCw,
|
|
separatorTop: true
|
|
},
|
|
{
|
|
displayName: 'Skip tutorials',
|
|
action: () => skipAllTodos(),
|
|
icon: CheckCheck
|
|
}
|
|
]
|
|
},
|
|
{
|
|
displayName: 'Test flow & record',
|
|
icon: Disc,
|
|
action: () => flowPreviewButtons?.openRecordingPreview()
|
|
}
|
|
]
|
|
}
|
|
|
|
function onCustomUiChange(
|
|
customUi: FlowBuilderWhitelabelCustomUi | undefined,
|
|
hasAiDiff: boolean
|
|
) {
|
|
baseMenuItems = [
|
|
...(customUi?.topBar?.history != false
|
|
? [
|
|
{
|
|
displayName: 'Deployment History',
|
|
icon: HistoryIcon,
|
|
action: () => {
|
|
flowHistory?.open()
|
|
},
|
|
disabled: newFlow
|
|
}
|
|
]
|
|
: []),
|
|
...(customUi?.topBar?.export != false
|
|
? [
|
|
{
|
|
displayName: 'Export',
|
|
icon: FileJson,
|
|
action: () => jsonViewerDrawer?.openDrawer()
|
|
},
|
|
{
|
|
displayName: 'Edit in YAML',
|
|
icon: FileJson,
|
|
action: () => yamlEditorDrawer?.openDrawer(),
|
|
disabled: hasAiDiff
|
|
}
|
|
]
|
|
: []),
|
|
...(customUi?.topBar?.settings != false
|
|
? [
|
|
{
|
|
displayName: 'Flow settings',
|
|
icon: Settings,
|
|
action: () => {
|
|
select('settings-metadata')
|
|
}
|
|
}
|
|
]
|
|
: [])
|
|
]
|
|
}
|
|
|
|
function handleDeployTrigger(_trigger: Trigger) {}
|
|
|
|
let forceTestTab: Record<string, boolean> = $state({})
|
|
let highlightArg: Record<string, string | undefined> = $state({})
|
|
|
|
$effect.pre(() => {
|
|
initialPathStore.set(initialPath)
|
|
})
|
|
$effect.pre(() => {
|
|
setContext('customUi', customUi)
|
|
})
|
|
$effect.pre(() => {
|
|
if (flowStore.val || selectedIdStore) {
|
|
readFieldsRecursively(flowStore.val)
|
|
}
|
|
})
|
|
// Sync `$pathStore` from `flowStore.val.path` (which `initFlow` populates
|
|
// from the loaded flow — including the draft's rename, when there is one).
|
|
// This effect only tracks `flowStore.val.path`, so popover edits that go
|
|
// straight to `$pathStore` don't trigger it and aren't overwritten.
|
|
// Replaces the previous `$pathStore = initialPath` push (added in #2536 for
|
|
// the VSCode extension), which silently dropped any draft-renamed path
|
|
// because `initialPath` is the URL, not the loaded path.
|
|
$effect.pre(() => {
|
|
// `flowStore.val` is typed `OpenFlow` here but `initFlow` actually puts a
|
|
// `Flow` (with `path`) in it.
|
|
const p = (flowStore.val as Flow | undefined)?.path
|
|
if (p) untrack(() => ($pathStore = p))
|
|
})
|
|
|
|
// Persist the user-typed path into the draft JSON as `draft_path`
|
|
// when it differs from the deployed/seeded `flow.path`. The Path
|
|
// widget binds `$pathStore` one-way to the popover input — without
|
|
// this, the friendly auto-name on `/flows/add` and any in-place
|
|
// rename never reach the autosaved Flow, so the home-list draft row
|
|
// kept showing the autogenerated `u/{user}/draft_{uuid}` slot. Drop
|
|
// the field once it matches the baseline again so it doesn't
|
|
// linger after a revert; deploy clears the whole draft, so the
|
|
// field naturally disappears post-deploy too.
|
|
$effect(() => {
|
|
const typed = $pathStore
|
|
const baseline = (flowStore.val as Flow | undefined)?.path ?? ''
|
|
const flow = flowStore.val as (Flow & { draft_path?: string }) | undefined
|
|
if (!flow) return
|
|
untrack(() => {
|
|
if (typed && typed !== baseline) {
|
|
flow.draft_path = typed
|
|
} else if (flow.draft_path !== undefined) {
|
|
delete (flow as any).draft_path
|
|
}
|
|
})
|
|
})
|
|
|
|
$effect.pre(() => {
|
|
selectedId && untrack(() => select(selectedId))
|
|
})
|
|
$effect.pre(() => {
|
|
initialPath && initialPath != '' && $workspaceStore && untrack(() => loadTriggers())
|
|
})
|
|
$effect.pre(() => {
|
|
const hasAiDiff = aiChatManager.flowAiChatHelpers?.hasPendingChanges() ?? false
|
|
customUi && untrack(() => onCustomUiChange(customUi, hasAiDiff))
|
|
})
|
|
|
|
export async function loadFlowState() {
|
|
await stepHistoryLoader.loadIndividualStepsStates(
|
|
flowStore.val as Flow,
|
|
flowStateStore,
|
|
$workspaceStore!,
|
|
$initialPathStore,
|
|
$pathStore
|
|
)
|
|
}
|
|
|
|
let stepHistoryLoader = new StepHistoryLoader(
|
|
untrack(() => loadedFromHistoryFromUrl)?.stepsState ?? {},
|
|
untrack(() => loadedFromHistoryFromUrl)?.flowJobInitial,
|
|
undefined,
|
|
untrack(() => noInitial)
|
|
)
|
|
setStepHistoryLoaderContext(stepHistoryLoader)
|
|
|
|
export function setLoadedFromHistory(
|
|
loadedFromHistoryUrl:
|
|
| {
|
|
flowJobInitial: boolean | undefined
|
|
stepsState: Record<string, stepState>
|
|
}
|
|
| undefined
|
|
) {
|
|
if (!loadedFromHistoryUrl) {
|
|
return
|
|
}
|
|
|
|
stepHistoryLoader.setFlowJobInitial(loadedFromHistoryUrl.flowJobInitial)
|
|
stepHistoryLoader.stepStates = loadedFromHistoryUrl.stepsState
|
|
}
|
|
|
|
function onJobDone() {
|
|
if (!job) {
|
|
return
|
|
}
|
|
// job was running and is now stopped
|
|
if (!flowPreviewButtons?.getPreviewOpen()) {
|
|
if (
|
|
job.type === 'CompletedJob' &&
|
|
job.success &&
|
|
flowPreviewButtons?.getPreviewMode() === 'whole'
|
|
) {
|
|
if (flowEditor?.isNodeVisible('Result') && selectedIdStore !== 'Result') {
|
|
outputPickerOpenFns['Result']?.()
|
|
}
|
|
} else {
|
|
// Find last module with a job in flow_status
|
|
const lastModuleWithJob = job.flow_status?.modules
|
|
?.slice()
|
|
.reverse()
|
|
.find((module) => 'job' in module)
|
|
if (
|
|
lastModuleWithJob &&
|
|
lastModuleWithJob.id &&
|
|
flowEditor?.isNodeVisible(lastModuleWithJob.id)
|
|
) {
|
|
outputPickerOpenFns[lastModuleWithJob.id]?.()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let localModuleStates: Record<string, GraphModuleState> = $state({})
|
|
let suspendStatus: StateStore<Record<string, { job: Job; nb: number }>> = $state({ val: {} })
|
|
|
|
const flowHasChanged = $derived(flowPreviewContent?.flowHasChanged())
|
|
</script>
|
|
|
|
<svelte:window onkeydown={onKeyDown} />
|
|
|
|
{@render children?.()}
|
|
|
|
<DeployOverrideConfirmationModal
|
|
{deployedBy}
|
|
{confirmCallback}
|
|
bind:open
|
|
{diffDrawer}
|
|
bind:deployedValue
|
|
currentValue={flowStore.val}
|
|
/>
|
|
|
|
<DraftTriggersConfirmationModal
|
|
bind:open={draftTriggersModalOpen}
|
|
draftTriggers={triggersState.triggers.filter((t) => t.draftConfig)}
|
|
isFlow={true}
|
|
on:canceled={() => {
|
|
draftTriggersModalOpen = false
|
|
}}
|
|
on:confirmed={handleDraftTriggersConfirmed}
|
|
/>
|
|
|
|
<AIChangesWarningModal bind:open={aiChangesWarningOpen} onConfirm={aiChangesConfirmCallback} />
|
|
|
|
{#key renderCount}
|
|
{#if !$userStore?.operator}
|
|
{#if $pathStore}
|
|
<FlowHistory bind:this={flowHistory} path={$pathStore} {onHistoryRestore} />
|
|
{/if}
|
|
<FlowYamlEditor bind:drawer={yamlEditorDrawer} />
|
|
<FlowImportExportMenu bind:drawer={jsonViewerDrawer} />
|
|
<ScriptEditorDrawer bind:this={$scriptEditorDrawer} />
|
|
<FlowEditorDrawer bind:this={$flowEditorDrawer} />
|
|
|
|
<div bind:this={flowBuilderRoot} class="flex flex-col h-screen">
|
|
<!-- Nav between steps-->
|
|
<div
|
|
bind:clientWidth={topbarWidth}
|
|
class="justify-between flex flex-row items-center pl-2 pr-4 space-x-4 scrollbar-hidden overflow-x-auto max-h-12 h-full relative"
|
|
>
|
|
<div class="flex flex-row items-center gap-2 min-w-[200px] max-w-full">
|
|
<EditorHeader
|
|
bind:summary={flowStore.val.summary}
|
|
bind:path={$pathStore}
|
|
savedPath={initialPath}
|
|
onBehalfOfEmail={$savedOnBehalfOfEmail}
|
|
onNavigate={(item) => onNavigate?.(item)}
|
|
/>
|
|
{#if indicatorWorkspace && indicatorPath !== undefined}
|
|
<AutosaveIndicator
|
|
workspace={indicatorWorkspace}
|
|
itemKind="flow"
|
|
path={indicatorPath}
|
|
draftOnly={newFlow}
|
|
{onResetToDeployed}
|
|
{loadedFromDraft}
|
|
{othersDraftsCount}
|
|
{onOpenOthersDrafts}
|
|
/>
|
|
{/if}
|
|
</div>
|
|
<div class="flex flex-row gap-2 items-center">
|
|
{#if $enterpriseLicense && !newFlow}
|
|
<Awareness />
|
|
{/if}
|
|
<div class="relative">
|
|
<Dropdown items={getMoreItems} />
|
|
{#if $tutorialsToDo.includes(getTutorialIndex('flow-live-tutorial')) || $tutorialsToDo.includes(getTutorialIndex('troubleshoot-flow'))}
|
|
<span
|
|
class="absolute top-0.5 right-0.5 block w-2 h-2 rounded-full bg-surface-accent-primary pointer-events-none"
|
|
></span>
|
|
{/if}
|
|
</div>
|
|
{#if customUi?.topBar?.diff != false}
|
|
{@const isDraftOnly = savedFlow?.no_deployed === true}
|
|
{@const diffDisabled = !savedFlow || newFlow || isDraftOnly}
|
|
{@const diffTitle =
|
|
newFlow || isDraftOnly
|
|
? 'Deploy this flow once to compare against the deployed version'
|
|
: 'Diff'}
|
|
<!-- A disabled <button> fires no pointer events, so a title/tooltip on
|
|
it never shows on hover. pointer-events-none on the button lets the
|
|
hover reach this titled wrapper instead. -->
|
|
<div title={diffTitle} class={diffDisabled ? 'flex cursor-not-allowed' : 'flex'}>
|
|
<Button
|
|
variant="default"
|
|
unifiedSize="md"
|
|
on:click={() => openDiffDrawer()}
|
|
disabled={diffDisabled}
|
|
btnClasses={diffDisabled ? 'pointer-events-none' : undefined}
|
|
iconOnly={compactTopbar}
|
|
title={diffTitle}
|
|
startIcon={{ icon: DiffIcon }}
|
|
>
|
|
Diff
|
|
</Button>
|
|
</div>
|
|
{/if}
|
|
{#if !compactTopbar}
|
|
{@render previewButtons()}
|
|
{/if}
|
|
|
|
<DeployButton
|
|
on:save={async ({ detail }) => await handleSaveFlow(detail)}
|
|
{loading}
|
|
{loadingSave}
|
|
{dropdownItems}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<!-- Rendered either inline in the top bar (wide) or as a graph overlay
|
|
(compactTopbar). Crossing the 720px threshold remounts
|
|
FlowPreviewButtons; any open preview state will reset. -->
|
|
{#snippet previewButtons()}
|
|
<FlowPreviewButtons
|
|
{suspendStatus}
|
|
on:openTriggers={(e) => {
|
|
select('Trigger')
|
|
handleSelectTriggerFromKind(triggersState, triggersCount, initialPath, e.detail.kind)
|
|
captureOn.set(true)
|
|
showCaptureHint.set(true)
|
|
}}
|
|
{onJobDone}
|
|
bind:localModuleStates
|
|
bind:this={flowPreviewButtons}
|
|
{loading}
|
|
onRunPreview={(jobId) => {
|
|
stepsInputArgs.resetManuallyEditedArgs()
|
|
modulesTestStates.hideJobsInGraph()
|
|
localModuleStates = {}
|
|
showJobStatus = true
|
|
if (jobId) {
|
|
onTestJob?.({ jobId })
|
|
}
|
|
}}
|
|
/>
|
|
{/snippet}
|
|
<!-- metadata -->
|
|
{#if flowStateStore.val}
|
|
<FlowEditor
|
|
bind:this={flowEditor}
|
|
graphOverlay={compactTopbar ? previewButtons : undefined}
|
|
{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') {
|
|
stepsInputArgs.setStepArgs('preprocessor', ev.detail.args ?? {})
|
|
selectionManager.selectId('preprocessor')
|
|
}
|
|
}}
|
|
on:testWithArgs={(e) => {
|
|
previewArgsStore.val = JSON.parse(JSON.stringify(e.detail))
|
|
flowPreviewButtons?.openPreview(true)
|
|
}}
|
|
onTestUpTo={(id) => {
|
|
flowPreviewButtons?.testUpTo(id)
|
|
}}
|
|
{savedFlow}
|
|
onDeployTrigger={handleDeployTrigger}
|
|
onEditInput={(moduleId, key) => {
|
|
selectionManager.selectId(moduleId)
|
|
// Use new prop-based system
|
|
forceTestTab[moduleId] = true
|
|
highlightArg[moduleId] = key
|
|
// Reset the force flag after a short delay to allow re-triggering
|
|
setTimeout(() => {
|
|
forceTestTab[moduleId] = false
|
|
highlightArg[moduleId] = undefined
|
|
}, 500)
|
|
}}
|
|
{forceTestTab}
|
|
{highlightArg}
|
|
aiChatOpen={aiChatManager.open}
|
|
showFlowAiButton={!disableAi && customUi?.topBar?.aiBuilder != false}
|
|
toggleAiChat={() => aiChatManager.toggleOpen()}
|
|
onOpenPreview={flowPreviewButtons?.openPreview}
|
|
localModuleStates={showJobStatus ? localModuleStates : {}}
|
|
{showJobStatus}
|
|
testModuleStates={modulesTestStates}
|
|
isOwner={flowPreviewContent?.getIsOwner()}
|
|
onTestFlow={flowPreviewButtons?.runPreview}
|
|
isRunning={flowPreviewContent?.getIsRunning()}
|
|
onCancelTestFlow={flowPreviewContent?.cancelTest}
|
|
onHideJobStatus={() => {
|
|
modulesTestStates.hideJobsInGraph()
|
|
showJobStatus = false
|
|
}}
|
|
{job}
|
|
{suspendStatus}
|
|
onDelete={(id) => {
|
|
delete localModuleStates[id]
|
|
delete modulesTestStates.states[id]
|
|
}}
|
|
{flowHasChanged}
|
|
previewOpen={flowPreviewButtons?.getPreviewOpen() ?? false}
|
|
/>
|
|
{:else}
|
|
<CenteredPage>Loading...</CenteredPage>
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
Flow Builder not available to operators
|
|
{/if}
|
|
{/key}
|
|
|
|
<FlowTutorials
|
|
bind:this={flowTutorials}
|
|
on:reload={() => {
|
|
renderCount += 1
|
|
}}
|
|
/>
|
|
|
|
<FlowAssetsHandler
|
|
modules={flowStore.val.value.modules}
|
|
enableParser
|
|
enableDbExplore
|
|
enablePathScriptAndFlowAssets
|
|
/>
|