From cec2fac7e46fb68484c0626bfeda6d8d87b94f60 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Wed, 9 Jul 2025 17:24:03 +0100 Subject: [PATCH] feat(frontend): run test flow from graph (#6122) * migrate FlowPreviewContent to svelte 5 * run background preview from input panel * share local run test * Show approval in graph is testing in graph * use component and props instead of portal for approval in graph * Add a toggle to show module status in graph * open module result after each run * Fix module reactivity issue * Add test flow button * Extract preview run logic from flowPreviewContent * Revert "Extract preview run logic from flowPreviewContent" This reverts commit a39c70a920286084c0a89194ed75aade903a2d61. * nit * lazy load preview content * create component for flow preview button * open preview v0 * open preview v1 * connect open preview button * improve graph run display * enable cancel preview * Run test flow from input panel * nit * wip * Use global context instead of module context for moduleTestState * nit * fix flow preview rendering * Add testJob to modulesTest context * update module status based on individual test data * fix: clear job status on run preview * detatch run buttons from input node * move preview job in FlowEditorContext * move outputPickerOpenFns to FlowEditorContext * add result panel * Add result output picker * add status to loops and branch * add open detail button to result panel * fix test up to * clean unnecessary binding * clean * Make iteration annotation smaller in editmode * detatch test button to and aproval from node * prevent flow edition during execution * Prevent step test run during flow run * Show approval in graph edges * prevent opening output popover if node is outside the graph * fix pointerdownOutside action * fix test up to dropdown not closing * fix test up to * nit * change job status badge display * fix running status * Enable test flow in Dev * fix darkmode * fix node panel display in Dev * fix test flow button positionning * fix suspend in subflows * improve lazy load of preview * prevent preview data unmount on close drawer * clean code * move flowjob into flow context * Revert "move flowjob into flow context" This reverts commit 939e9dbaafc91bc3c9be9b944630a540c662dee1. * clean context * nit * fix dark mode status view * fix test button alignment * clean job status on deleted step * fix retry bad status display * Detect flow change * Update frontend/src/lib/components/flows/header/FlowPreviewButtons.svelte Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> --------- Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> --- frontend/src/lib/components/Dev.svelte | 121 +++- .../src/lib/components/FlowBuilder.svelte | 121 +++- .../lib/components/FlowPreviewContent.svelte | 653 ++++++++++-------- .../lib/components/FlowPreviewResult.svelte | 103 +++ .../lib/components/FlowStatusViewer.svelte | 4 +- .../components/FlowStatusViewerInner.svelte | 86 +-- .../FlowStatusWaitingForEvents.svelte | 7 +- frontend/src/lib/components/ModuleTest.svelte | 54 +- .../components/common/button/Button.svelte | 3 + .../lib/components/flows/FlowEditor.svelte | 59 +- .../flows/content/FlowEditorPanel.svelte | 23 +- .../components/flows/content/FlowInput.svelte | 6 +- .../flows/content/FlowResult.svelte | 50 ++ .../flows/header/FlowPreviewButtons.svelte | 102 ++- .../flows/map/FlowGraphPreviewButton.svelte | 124 ++++ .../flows/map/FlowModuleSchemaItem.svelte | 604 ++++++++-------- .../flows/map/FlowModuleSchemaMap.svelte | 52 +- .../lib/components/flows/map/MapItem.svelte | 40 +- .../components/flows/map/VirtualItem.svelte | 87 ++- .../flows/map/VirtualItemWrapper.svelte | 64 +- .../flows/propPicker/OutputPicker.svelte | 28 +- .../flows/propPicker/OutputPickerInner.svelte | 6 +- frontend/src/lib/components/flows/types.ts | 3 + frontend/src/lib/components/flows/utils.ts | 51 +- .../lib/components/graph/FlowGraphV2.svelte | 56 +- .../components/graph/ViewportResizer.svelte | 41 +- .../components/graph/graphBuilder.svelte.ts | 40 +- .../graph/renderers/edges/BaseEdge.svelte | 83 ++- .../graph/renderers/nodes/InputNode.svelte | 20 +- .../graph/renderers/nodes/ModuleNode.svelte | 19 +- .../graph/renderers/nodes/ResultNode.svelte | 20 +- .../src/lib/components/modulesTest.svelte.ts | 17 + .../preview/FlowPreviewStatus.svelte | 20 +- frontend/src/lib/utils.ts | 1 + frontend/src/routes/flows/dev/+page.svelte | 5 +- 35 files changed, 1953 insertions(+), 820 deletions(-) create mode 100644 frontend/src/lib/components/FlowPreviewResult.svelte create mode 100644 frontend/src/lib/components/flows/content/FlowResult.svelte create mode 100644 frontend/src/lib/components/flows/map/FlowGraphPreviewButton.svelte create mode 100644 frontend/src/lib/components/modulesTest.svelte.ts diff --git a/frontend/src/lib/components/Dev.svelte b/frontend/src/lib/components/Dev.svelte index 9908ed8af6..a91482e1fc 100644 --- a/frontend/src/lib/components/Dev.svelte +++ b/frontend/src/lib/components/Dev.svelte @@ -27,7 +27,7 @@ import FlowModuleSchemaMap from './flows/map/FlowModuleSchemaMap.svelte' import FlowEditorPanel from './flows/content/FlowEditorPanel.svelte' import { deepEqual } from 'fast-equals' - import { writable } from 'svelte/store' + import { writable, type Writable } from 'svelte/store' import type { FlowState } from './flows/flowState' import { initHistory } from '$lib/history' import type { FlowEditorContext, FlowInput, FlowInputEditorState } from './flows/types' @@ -49,6 +49,9 @@ import type { PickableProperties } from './flows/previousResults' import { Triggers } from './triggers/triggers.svelte' import { TestSteps } from './flows/testSteps.svelte' + import { ModulesTestStates } from './modulesTest.svelte' + import type { DurationStatus, GraphModuleState } from './graph' + import { updateDerivedModuleStatesFromTestJobs } from './flows/utils' let flowCopilotContext: FlowCopilotContext = { shouldUpdatePropertyType: writable<{ @@ -108,6 +111,12 @@ let pastPreviews: CompletedJob[] = $state([]) let validCode = $state(true) + // Flow preview + let flowPreviewButtons: FlowPreviewButtons | undefined = $state() + const job: Job | undefined = $derived(flowPreviewButtons?.getJob()) + let showJobStatus = $state(false) + let testModuleId: string | undefined = $state(undefined) + type LastEditScript = { content: string path: string @@ -436,11 +445,17 @@ const scriptEditorDrawer = writable(undefined) const moving = writable<{ id: string } | undefined>(undefined) const history = initHistory(flowStore.val) - const testSteps = new TestSteps() const selectedIdStore = writable('settings-metadata') - const triggersCount = writable(undefined) + const modulesTestStates = new ModulesTestStates((moduleId) => { + // Update the derived store with test job states + delete $derivedModuleStates[moduleId] + testModuleId = moduleId + showJobStatus = false + }) + const outputPickerOpenFns: Record void> = $state({}) + setContext('TriggerContext', { triggersCount: triggersCount, simplifiedPoll: writable(false), @@ -469,7 +484,9 @@ editPanelSize: undefined, payloadData: undefined }), - currentEditor: writable(undefined) + currentEditor: writable(undefined), + modulesTestStates, + outputPickerOpenFns }) setContext('PropPickerContext', { flowPropPickerConfig: writable(undefined), @@ -487,7 +504,6 @@ } } - let flowPreviewButtons: FlowPreviewButtons | undefined = $state() let reload = $state(0) async function inferModuleArgs(selectedIdStore: string) { @@ -554,6 +570,69 @@ $effect(() => { $selectedIdStore && untrack(() => inferModuleArgs($selectedIdStore)) }) + + const localModuleStates: Writable> = $derived( + flowPreviewButtons?.getLocalModuleStates() ?? writable({}) + ) + const localDurationStatuses: Writable> = $derived( + flowPreviewButtons?.getLocalDurationStatuses() ?? writable({}) + ) + const suspendStatus: Writable> = $derived( + flowPreviewButtons?.getSuspendStatus() ?? writable({}) + ) + + // Create a derived store that only shows the module states when showModuleStatus is true + // this store can also be updated + let derivedModuleStates = writable>({}) + $effect(() => { + derivedModuleStates.update((currentStates) => { + return showJobStatus ? $localModuleStates : currentStates + }) + }) + $effect(() => { + updateDerivedModuleStatesFromTestJobs(testModuleId, modulesTestStates, derivedModuleStates) + }) + + let flowModuleSchemaMap: FlowModuleSchemaMap | undefined = $state() + 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 (flowModuleSchemaMap?.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 && + flowModuleSchemaMap?.isNodeVisible(lastModuleWithJob.id) + ) { + outputPickerOpenFns[lastModuleWithJob.id]?.() + } + } + } + } + + function resetModulesStates() { + derivedModuleStates.set({}) + showJobStatus = false + } + + const individualStepTests = $derived( + !(showJobStatus && job) && Object.keys($derivedModuleStates).length > 0 + ) @@ -691,18 +770,40 @@ {/if} -
- +
+ { + localModuleStates.set({}) + showJobStatus = true + }} + />
{#if flowStore.val?.value?.modules}
{ + delete $derivedModuleStates[id] + }} /> {:else}
Missing flow modules
@@ -722,6 +823,12 @@ flowPreviewButtons?.openPreview() } }} + onTestFlow={flowPreviewButtons?.runPreview} + {job} + isOwner={flowPreviewButtons?.getIsOwner()} + {localDurationStatuses} + {suspendStatus} + onOpenDetails={flowPreviewButtons?.openPreview} /> {/key}
diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index 6bd72540a6..75f1f9cea0 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -7,7 +7,8 @@ type OpenFlow, type InputTransform, type TriggersCount, - CaptureService + CaptureService, + type Job } from '$lib/gen' import { initHistory, redo, undo } from '$lib/history' import { @@ -32,7 +33,7 @@ import AIChangesWarningModal from '$lib/components/copilot/chat/flow/AIChangesWarningModal.svelte' import { onMount, setContext, untrack, type ComponentType } from 'svelte' - import { writable } from 'svelte/store' + import { writable, type Writable } from 'svelte/store' import CenteredPage from './CenteredPage.svelte' import { Badge, Button, UndoRedo } from './common' import FlowEditor from './flows/FlowEditor.svelte' @@ -41,7 +42,7 @@ import FlowImportExportMenu from './flows/header/FlowImportExportMenu.svelte' import FlowPreviewButtons from './flows/header/FlowPreviewButtons.svelte' import type { FlowEditorContext, FlowInput, FlowInputEditorState } from './flows/types' - import { cleanInputs } from './flows/utils' + import { cleanInputs, updateDerivedModuleStatesFromTestJobs } from './flows/utils' import { Calendar, Pen, @@ -77,12 +78,14 @@ import { Triggers } from './triggers/triggers.svelte' import { TestSteps } from './flows/testSteps.svelte' import { aiChatManager } from './copilot/chat/AIChatManager.svelte' + import type { DurationStatus, GraphModuleState } from './graph' import { setStepHistoryLoaderContext, StepHistoryLoader, type stepState } from './stepHistoryLoader.svelte' import type { FlowBuilderProps } from './flow_builder' + import { ModulesTestStates } from './modulesTest.svelte' let { initialPath = $bindable(''), @@ -141,6 +144,12 @@ 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 @@ -567,6 +576,15 @@ } let insertButtonOpen = writable(false) + let testModuleId: string | undefined = $state(undefined) + let modulesTestStates = new ModulesTestStates((moduleId) => { + // Update the derived store with test job states + delete $derivedModuleStates[moduleId] + testModuleId = moduleId + showJobStatus = false + }) + let outputPickerOpenFns: Record void> = $state({}) + let flowEditor: FlowEditor | undefined = $state(undefined) setContext('FlowEditorContext', { selectedId: selectedIdStore, @@ -586,7 +604,9 @@ customUi, insertButtonOpen, executionCount: writable(0), - flowInputEditorState: flowInputEditorStateStore + flowInputEditorState: flowInputEditorStateStore, + modulesTestStates, + outputPickerOpenFns }) // Add triggers context store @@ -813,8 +833,6 @@ } } - let flowPreviewButtons: FlowPreviewButtons | undefined = $state() - let forceTestTab: Record = $state({}) let highlightArg: Record = $state({}) @@ -877,6 +895,70 @@ 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]?.() + } + } + } + } + + const localModuleStates: Writable> = $derived( + flowPreviewContent?.getLocalModuleStates() ?? writable({}) + ) + const localDurationStatuses: Writable> = $derived( + flowPreviewContent?.getLocalDurationStatuses() ?? writable({}) + ) + const suspendStatus: Writable> = $derived( + flowPreviewContent?.getSuspendStatus() ?? writable({}) + ) + + // Create a derived store that only shows the module states when showModuleStatus is true + // this store can also be updated + let derivedModuleStates = writable>({}) + $effect(() => { + derivedModuleStates.update((currentStates) => { + return showJobStatus ? $localModuleStates : currentStates + }) + }) + $effect(() => { + updateDerivedModuleStatesFromTestJobs(testModuleId, modulesTestStates, derivedModuleStates) + }) + + function resetModulesStates() { + derivedModuleStates.set({}) + showJobStatus = false + } + + const individualStepTests = $derived( + !(showJobStatus && job) && Object.keys($derivedModuleStates).length > 0 + ) + + const flowHasChanged = $derived(flowPreviewContent?.flowHasChanged()) @@ -1069,8 +1151,13 @@ captureOn.set(true) showCaptureHint.set(true) }} + {onJobDone} bind:this={flowPreviewButtons} {loading} + onRunPreview={() => { + localModuleStates.set({}) + showJobStatus = true + }} />
+ + {#if isRunning} +
+ +
+ {:else} +
+ {#if jobId !== undefined && selectedJobStep !== undefined && selectedJobStepIsTopLevel && aiChatManager.flowAiChatHelpers?.getModuleAction(selectedJobStep) !== 'removed'} + {#if selectedJobStepType == 'single'} + + {:else} + + {#snippet button()} + + {/snippet} + {#snippet content()} + + {/snippet} + + {/if} + {/if} + +
+ {/if} - {#if isRunning} -
- -
- {:else} -
- {#if jobId !== undefined && selectedJobStep !== undefined && selectedJobStepIsTopLevel && aiChatManager.flowAiChatHelpers?.getModuleAction(selectedJobStep) !== 'removed'} - {#if selectedJobStepType == 'single'} - - {:else} - - - - - - - - - {/if} - {/if} - -
- {/if} - -
- {#if lastPreviewFlow && JSON.stringify(flowStore.val) != lastPreviewFlow} -
-
- Flow changed since last preview -
+
+ {#if flowHasChanged()} +
+
+ Flow changed since last preview +
+
-
- {/if} - -
- + {/if} + +
+ {/if}
handleScroll()} + onscroll={(e) => handleScroll()} > -
- { - selectInput(e.detail.payload, e.detail?.type) - }} - {isValid} - {jsonView} - > -
- schemaFormWithArgPicker?.resetSelected()} - {inputSelected} - /> -
- { - jsonEditor?.setCode(JSON.stringify(previewArgs.val ?? {}, null, '\t')) - refresh() - }} + {#if render} +
+ { + selectInput(e.detail.payload, e.detail?.type) + }} + {isValid} + {jsonView} + > +
+ schemaFormWithArgPicker?.resetSelected()} + {inputSelected} /> -
-
- {#if jsonView} -
- { - if (e.detail) { - previewArgs.val = e.detail - } - }} - updateOnBlur={false} - placeholder={`Write args as JSON.

Example:

{
  "foo": "12"
}`} - /> -
- {:else} - {#key renderCount} -
- { - savedArgs = previewArgs.val +
+ { + jsonEditor?.setCode(JSON.stringify(previewArgs.val ?? {}, null, '\t')) + refresh() }} - bind:isValid />
- {/key} - {/if} - -
+
+ {#if jsonView} +
+ { + if (e.detail) { + previewArgs.val = e.detail + } + }} + updateOnBlur={false} + placeholder={`Write args as JSON.

Example:

{
  "foo": "12"
}`} + /> +
+ {:else} + {#key renderCount} +
+ { + savedArgs = previewArgs.val + }} + bind:isValid + /> +
+ {/key} + {/if} + +
+ {/if}
- { - if (!currentJobId) { - currentJobId = jobId - } - const detail = e.detail - jobId = detail.jobId - if (detail.initial && stepHistoryLoader?.flowJobInitial === undefined) { - stepHistoryLoader?.setFlowJobInitial(detail.initial) - } - }} - on:unselect={() => { - jobId = currentJobId - currentJobId = undefined - }} - path={$initialPathStore == '' ? $pathStore : $initialPathStore} - /> + {#if render} + { + if (!currentJobId) { + currentJobId = jobId + } + const detail = e.detail + jobId = detail.jobId + if (detail.initial && stepHistoryLoader?.flowJobInitial === undefined) { + stepHistoryLoader?.setFlowJobInitial(detail.initial) + } + }} + on:unselect={() => { + jobId = currentJobId + currentJobId = undefined + }} + path={$initialPathStore == '' ? $pathStore : $initialPathStore} + /> + {/if}
+ {#if jobId} {#if stepHistoryLoader?.flowJobInitial} - - +
{ + onclick={() => { stepHistoryLoader?.setFlowJobInitial(false) }} class="cursor-pointer h-full hover:bg-gray-500/20 dark:hover:bg-gray-500/20 dark:bg-gray-500/80 rounded bg-gray-500/40 absolute top-0 left-0 w-full z-50" @@ -487,15 +551,20 @@ bind:job bind:localModuleStates bind:localDurationStatuses + bind:suspendStatus hideDownloadInGraph={customUi?.downloadLogs === false} wideResults {flowStateStore} {jobId} on:done={() => { + isRunning = false $executionCount = $executionCount + 1 + onJobDone?.() }} bind:selectedJobStep bind:rightColumnSelect + bind:isOwner + {render} /> {:else}
Flow status will be displayed here
diff --git a/frontend/src/lib/components/FlowPreviewResult.svelte b/frontend/src/lib/components/FlowPreviewResult.svelte new file mode 100644 index 0000000000..d92192fadb --- /dev/null +++ b/frontend/src/lib/components/FlowPreviewResult.svelte @@ -0,0 +1,103 @@ + + + + +{#if !job} +
+ +
+{:else if `result` in job} + {#if !hideFlowResult} +
+ +
+ {/if} +{:else if job.flow_status?.modules?.[job?.flow_status?.step]?.type === 'WaitingForEvents'} + +{:else if $suspendStatus && Object.keys($suspendStatus).length > 0} +
+ {#each Object.values($suspendStatus) as suspendCount (suspendCount.job.id)} +
+
+ Flow suspended, waiting for {suspendCount.nb} events +
+ +
+ {/each} +
+{:else if job.logs} +
+
{job.logs}
+
+{:else if innerModules?.length > 0} +
+ {#each innerModules as mod, i (mod.id)} + {#if mod.type == 'InProgress'} + {@const rawMod = job.raw_flow?.modules[i]} + +
{mod.id} + + {#if !emptyString(rawMod?.summary)} + {rawMod?.summary ?? ''} + {:else if rawMod?.value.type == 'script'} + {rawMod.value.path ?? ''} + {:else if rawMod?.value.type} + {rawMod?.value.type} + {/if} + + +
+ {/if} + {/each} +
+{/if} diff --git a/frontend/src/lib/components/FlowStatusViewer.svelte b/frontend/src/lib/components/FlowStatusViewer.svelte index ff58e71c90..9afcd99f85 100644 --- a/frontend/src/lib/components/FlowStatusViewer.svelte +++ b/frontend/src/lib/components/FlowStatusViewer.svelte @@ -26,11 +26,12 @@ export let localModuleStates: Writable> = writable({}) export let localDurationStatuses: Writable> = writable({}) export let job: Job | undefined = undefined + export let render = true + export let suspendStatus = writable({}) let lastJobId: string = jobId let retryStatus = writable({}) - let suspendStatus = writable({}) setContext('FlowStatusViewer', { flowStateStore, suspendStatus, @@ -85,4 +86,5 @@ {isOwner} {wideResults} bind:rightColumnSelect + {render} /> diff --git a/frontend/src/lib/components/FlowStatusViewerInner.svelte b/frontend/src/lib/components/FlowStatusViewerInner.svelte index 6d3c3892c6..740d0684ca 100644 --- a/frontend/src/lib/components/FlowStatusViewerInner.svelte +++ b/frontend/src/lib/components/FlowStatusViewerInner.svelte @@ -10,7 +10,6 @@ import { workspaceStore } from '$lib/stores' import { base } from '$lib/base' import FlowJobResult from './FlowJobResult.svelte' - import FlowPreviewStatus from './preview/FlowPreviewStatus.svelte' import DisplayResult from './DisplayResult.svelte' import { createEventDispatcher, getContext, tick } from 'svelte' @@ -19,10 +18,9 @@ import Tabs from './common/tabs/Tabs.svelte' import { type DurationStatus, type FlowStatusViewerContext, type GraphModuleState } from './graph' import ModuleStatus from './ModuleStatus.svelte' - import { emptyString, isScriptPreview, msToSec, truncateRev } from '$lib/utils' + import { isScriptPreview, msToSec, truncateRev } from '$lib/utils' import JobArgs from './JobArgs.svelte' - import { ChevronDown, Hourglass, Loader2 } from 'lucide-svelte' - import FlowStatusWaitingForEvents from './FlowStatusWaitingForEvents.svelte' + import { ChevronDown, Hourglass } from 'lucide-svelte' import { deepEqual } from 'fast-equals' import FlowTimeline from './FlowTimeline.svelte' import { dfs } from './flows/dfs' @@ -31,6 +29,7 @@ import FlowGraphViewerStep from './FlowGraphViewerStep.svelte' import FlowGraphV2 from './graph/FlowGraphV2.svelte' import { buildPrefix } from './graph/graphBuilder.svelte' + import FlowPreviewResult from './FlowPreviewResult.svelte' const dispatch = createEventDispatcher() @@ -41,7 +40,8 @@ hideDownloadInGraph, hideTimeline, hideNodeDefinition, - hideDownloadLogs + hideDownloadLogs, + hideJobId } = getContext('FlowStatusViewer') export let jobId: string @@ -916,71 +916,17 @@ {/if} {:else if render}
- - {#if !job} -
- -
- {:else if `result` in job} - {#if !hideFlowResult} -
- -
- {/if} - {:else if job.flow_status?.modules?.[job?.flow_status?.step]?.type === 'WaitingForEvents'} - - {:else if $suspendStatus && Object.keys($suspendStatus).length > 0} -
- {#each Object.values($suspendStatus) as suspendCount (suspendCount.job.id)} -
-
- Flow suspended, waiting for {suspendCount.nb} events -
- -
- {/each} -
- {:else if job.logs} -
-
{job.logs}
-
- {:else if innerModules?.length > 0} -
- {#each innerModules as mod, i (mod.id)} - {#if mod.type == 'InProgress'} - {@const rawMod = job.raw_flow?.modules[i]} - -
{mod.id} - - {#if !emptyString(rawMod?.summary)} - {rawMod?.summary ?? ''} - {:else if rawMod?.value.type == 'script'} - {rawMod.value.path ?? ''} - {:else if rawMod?.value.type} - {rawMod?.value.type} - {/if} - - -
- {/if} - {/each} -
- {/if} +
{/if} {#if render} diff --git a/frontend/src/lib/components/FlowStatusWaitingForEvents.svelte b/frontend/src/lib/components/FlowStatusWaitingForEvents.svelte index da42170473..1ac347b786 100644 --- a/frontend/src/lib/components/FlowStatusWaitingForEvents.svelte +++ b/frontend/src/lib/components/FlowStatusWaitingForEvents.svelte @@ -12,6 +12,7 @@ export let isOwner: boolean export let workspaceId: string | undefined export let job: Job + export let light: boolean = false let default_payload: object = {} let resumeUrl: string | undefined = undefined @@ -115,9 +116,11 @@
-

Waiting to be resumed

+ {#if !light} +

Waiting to be resumed

+ {/if} {#if description != undefined} - + {/if}
{#if isOwner || resumeUrl} diff --git a/frontend/src/lib/components/ModuleTest.svelte b/frontend/src/lib/components/ModuleTest.svelte index f12e791003..02ec203898 100644 --- a/frontend/src/lib/components/ModuleTest.svelte +++ b/frontend/src/lib/components/ModuleTest.svelte @@ -1,17 +1,8 @@ - - testModulesState[mod.id]?.loading ?? false, + () => modulesTestStates.states[mod.id]?.loading ?? false, (v) => { let newLoading = v ?? false - if (testModulesState[mod.id]?.loading !== newLoading) { - testModulesState[mod.id] = { - ...(testModulesState[mod.id] ?? {}), + if (modulesTestStates.states[mod.id]?.loading !== newLoading) { + modulesTestStates.states[mod.id] = { + ...(modulesTestStates.states[mod.id] ?? {}), loading: newLoading } } } } - bind:job={testJob} + bind:job={modulesTestStates.states[mod.id].testJob} /> diff --git a/frontend/src/lib/components/common/button/Button.svelte b/frontend/src/lib/components/common/button/Button.svelte index 4d861be88f..64099a539b 100644 --- a/frontend/src/lib/components/common/button/Button.svelte +++ b/frontend/src/lib/components/common/button/Button.svelte @@ -62,6 +62,7 @@ children?: import('svelte').Snippet tooltip?: import('svelte').Snippet [key: string]: any + dropdownOpen?: boolean } let { @@ -97,6 +98,7 @@ hideDropdown = false, children, tooltip, + dropdownOpen = $bindable(false), ...rest }: Props = $props() @@ -343,6 +345,7 @@ usePointerDownOutside on:open={() => dispatch('dropdownOpen', true)} on:close={() => dispatch('dropdownOpen', false)} + bind:open={dropdownOpen} > {#snippet buttonReplacement()}
('FlowEditorContext') @@ -39,7 +40,20 @@ aiChatOpen?: boolean showFlowAiButton?: boolean toggleAiChat?: () => void - onRunPreview?: () => void + localModuleStates?: Writable> + isOwner?: boolean + onTestFlow?: () => void + isRunning?: boolean + onCancelTestFlow?: () => void + onOpenPreview?: () => void + onHideJobStatus?: () => void + individualStepTests?: boolean + job?: Job + localDurationStatuses?: Writable> + suspendStatus?: Writable> + showJobStatus?: boolean + onDelete?: (id: string) => void + flowHasChanged?: boolean } let { @@ -57,14 +71,31 @@ onEditInput = undefined, forceTestTab, highlightArg, + localModuleStates = writable({}), aiChatOpen, showFlowAiButton, toggleAiChat, - onRunPreview = () => {} + isOwner, + onTestFlow, + isRunning, + onCancelTestFlow, + onOpenPreview, + onHideJobStatus, + individualStepTests = false, + job, + localDurationStatuses, + suspendStatus, + showJobStatus, + onDelete, + flowHasChanged }: Props = $props() let flowModuleSchemaMap: FlowModuleSchemaMap | undefined = $state() + export function isNodeVisible(nodeId: string): boolean { + return flowModuleSchemaMap?.isNodeVisible(nodeId) ?? false + } + setContext('PropPickerContext', { flowPropPickerConfig: writable(undefined), pickablePropertiesFiltered: writable(undefined) @@ -114,9 +145,22 @@ }} {onTestUpTo} {onEditInput} + {localModuleStates} {aiChatOpen} {showFlowAiButton} {toggleAiChat} + {isOwner} + {onTestFlow} + {isRunning} + {onCancelTestFlow} + {onOpenPreview} + {onHideJobStatus} + {individualStepTests} + flowJob={job} + {showJobStatus} + {suspendStatus} + {onDelete} + {flowHasChanged} /> {/if}
@@ -139,7 +183,12 @@ {onDeployTrigger} {forceTestTab} {highlightArg} - {onRunPreview} + {onTestFlow} + {job} + {isOwner} + {localDurationStatuses} + {suspendStatus} + onOpenDetails={onOpenPreview} /> {/if} diff --git a/frontend/src/lib/components/flows/content/FlowEditorPanel.svelte b/frontend/src/lib/components/flows/content/FlowEditorPanel.svelte index 463933e618..c078caed2f 100644 --- a/frontend/src/lib/components/flows/content/FlowEditorPanel.svelte +++ b/frontend/src/lib/components/flows/content/FlowEditorPanel.svelte @@ -6,13 +6,16 @@ import FlowInput from './FlowInput.svelte' import FlowFailureModule from './FlowFailureModule.svelte' import FlowConstants from './FlowConstants.svelte' - import type { FlowModule, Flow } from '$lib/gen' + import type { FlowModule, Flow, Job } from '$lib/gen' import FlowPreprocessorModule from './FlowPreprocessorModule.svelte' import type { TriggerContext } from '$lib/components/triggers' import { insertNewPreprocessorModule } from '../flowStateUtils.svelte' import TriggersEditor from '../../triggers/TriggersEditor.svelte' import { handleSelectTriggerFromKind, type Trigger } from '$lib/components/triggers/utils' import { computeMissingInputWarnings } from '../missingInputWarnings' + import FlowResult from './FlowResult.svelte' + import type { Writable } from 'svelte/store' + import type { DurationStatus } from '$lib/components/graph' interface Props { noEditor?: boolean @@ -27,7 +30,12 @@ onDeployTrigger?: (trigger: Trigger) => void forceTestTab?: Record highlightArg?: Record - onRunPreview?: () => void + onTestFlow?: () => void + job?: Job + isOwner?: boolean + localDurationStatuses?: Writable> + suspendStatus?: Writable> + onOpenDetails?: () => void } let { @@ -39,7 +47,12 @@ onDeployTrigger = () => {}, forceTestTab, highlightArg, - onRunPreview + onTestFlow, + job, + isOwner, + localDurationStatuses, + suspendStatus, + onOpenDetails }: Props = $props() const { @@ -84,10 +97,10 @@ showCaptureHint.set(true) }} on:applyArgs - {onRunPreview} + {onTestFlow} /> {:else if $selectedId === 'Result'} -

The result of the flow will be the result of the last node.

+ {:else if $selectedId === 'constants'} {:else if $selectedId === 'failure'} diff --git a/frontend/src/lib/components/flows/content/FlowInput.svelte b/frontend/src/lib/components/flows/content/FlowInput.svelte index 421d2e050b..fc297e252f 100644 --- a/frontend/src/lib/components/flows/content/FlowInput.svelte +++ b/frontend/src/lib/components/flows/content/FlowInput.svelte @@ -46,10 +46,10 @@ interface Props { noEditor: boolean disabled: boolean - onRunPreview?: () => void + onTestFlow?: () => void } - let { noEditor, disabled, onRunPreview }: Props = $props() + let { noEditor, disabled, onTestFlow }: Props = $props() const { flowStore, @@ -206,7 +206,7 @@ if (previewArguments) { previewArgs.val = structuredClone($state.snapshot(previewArguments)) } - onRunPreview?.() + onTestFlow?.() } function updatePreviewSchemaAndArgs(payload: any) { diff --git a/frontend/src/lib/components/flows/content/FlowResult.svelte b/frontend/src/lib/components/flows/content/FlowResult.svelte new file mode 100644 index 0000000000..1aa7a4f49e --- /dev/null +++ b/frontend/src/lib/components/flows/content/FlowResult.svelte @@ -0,0 +1,50 @@ + + + + {#if job && isOwner !== undefined && localDurationStatuses && suspendStatus} +
+ +
+ {:else} +

The result of the flow will be the result of the last node.

+ {/if} +
+ +{#snippet extra()} +
+ +
+{/snippet} diff --git a/frontend/src/lib/components/flows/header/FlowPreviewButtons.svelte b/frontend/src/lib/components/flows/header/FlowPreviewButtons.svelte index 80708fea50..576b73a754 100644 --- a/frontend/src/lib/components/flows/header/FlowPreviewButtons.svelte +++ b/frontend/src/lib/components/flows/header/FlowPreviewButtons.svelte @@ -3,25 +3,34 @@ import Drawer from '$lib/components/common/drawer/Drawer.svelte' import FlowPreviewContent from '$lib/components/FlowPreviewContent.svelte' - import type { Job } from '$lib/gen' import { createEventDispatcher, tick } from 'svelte' import { getContext } from 'svelte' import type { FlowEditorContext } from '../types' import { Play } from 'lucide-svelte' - import { writable, type Writable } from 'svelte/store' - import type { DurationStatus, GraphModuleState } from '$lib/components/graph' import { aiChatManager } from '$lib/components/copilot/chat/AIChatManager.svelte' interface Props { loading?: boolean + onRunPreview?: () => void + onJobDone?: () => void } - let { loading = false }: Props = $props() + let { loading = false, onRunPreview, onJobDone }: Props = $props() const { selectedId } = getContext('FlowEditorContext') - let previewOpen = $state(false) + + let flowPreviewContent: FlowPreviewContent | undefined = $state(undefined) + let preventEscape = $state(false) + let selectedJobStep: string | undefined = $state(undefined) + let selectedJobStepIsTopLevel: boolean | undefined = $state(undefined) + let selectedJobStepType: 'single' | 'forloop' | 'branchall' = $state('single') + let branchOrIterationN: number = $state(0) + let scrollTop: number = $state(0) let previewMode: 'upTo' | 'whole' = $state('whole') + let upToId: string | undefined = $state(undefined) + let previewOpen = $state(false) + let deferContent = $state(false) export async function openPreview(test: boolean = false) { if (!previewOpen) { @@ -30,29 +39,33 @@ flowPreviewContent?.refresh() if (!test) return } + previewMode = 'whole' flowPreviewContent?.test() } + export async function runPreview() { + if (!previewOpen) { + deferContent = true + await tick() + } + previewMode = 'whole' + flowPreviewContent?.refresh() + flowPreviewContent?.test() + } + + export function cancelTest() { + flowPreviewContent?.cancelTest() + } + const dispatch = createEventDispatcher() - let flowPreviewContent: FlowPreviewContent | undefined = $state(undefined) - let jobId: string | undefined = $state(undefined) - let job: Job | undefined = $state(undefined) - let preventEscape = $state(false) - let selectedJobStep: string | undefined = $state(undefined) - let selectedJobStepIsTopLevel: boolean | undefined = $state(undefined) - let selectedJobStepType: 'single' | 'forloop' | 'branchall' = $state('single') - let branchOrIterationN: number = $state(0) - let scrollTop: number = $state(0) - let rightColumnSelect: 'timeline' | 'node_status' | 'node_definition' | 'user_states' = $state('timeline') - let localModuleStates: Writable> = $state(writable({})) - let localDurationStatuses: Writable> = $state(writable({})) - - let upToDisabled = $derived( - $selectedId == undefined || + let upToDisabled = $derived.by(() => { + const upToSelected = upToId ?? $selectedId + return ( + upToSelected == undefined || [ 'settings', 'settings-metadata', @@ -73,15 +86,38 @@ 'Result', 'Input', 'triggers' - ].includes($selectedId) || - $selectedId?.includes('branch') || - aiChatManager.flowAiChatHelpers?.getModuleAction($selectedId) === 'removed' - ) + ].includes(upToSelected) || + upToSelected?.includes('branch') || + aiChatManager.flowAiChatHelpers?.getModuleAction(upToSelected) === 'removed' + ) + }) - export function testUpTo() { + export async function testUpTo(id: string | undefined, openPreview: boolean = false) { + upToId = id if (upToDisabled) return + if (openPreview) { + previewOpen = true + } else if (!previewOpen) { + deferContent = true + await tick() + } previewMode = 'upTo' - previewOpen = true + flowPreviewContent?.refresh() + if (!openPreview) { + flowPreviewContent?.test() + } + } + + export function getPreviewMode() { + return previewMode + } + + export function getPreviewOpen() { + return previewOpen + } + + export function getFlowPreviewContent() { + return flowPreviewContent } @@ -101,7 +137,7 @@ ? [ { label: 'Test up to ' + $selectedId, - onClick: testUpTo + onClick: () => testUpTo($selectedId, true) } ] : undefined} @@ -110,22 +146,20 @@ {#if !loading} - + { + // keep the data in the preview content + deferContent = true previewOpen = false }} on:openTriggers={(e) => { @@ -133,6 +167,10 @@ dispatch('openTriggers', e.detail) }} bind:preventEscape + {onRunPreview} + render={previewOpen} + {onJobDone} + {upToId} /> {/if} diff --git a/frontend/src/lib/components/flows/map/FlowGraphPreviewButton.svelte b/frontend/src/lib/components/flows/map/FlowGraphPreviewButton.svelte new file mode 100644 index 0000000000..2634d793d2 --- /dev/null +++ b/frontend/src/lib/components/flows/map/FlowGraphPreviewButton.svelte @@ -0,0 +1,124 @@ + + +{#if !isRunning} + + {#if wide && (flowPreviewJob || individualStepTests)} +
+ + + {#snippet text()} + {#if !individualStepTests && !flowHasChanged} + See run details + {:else} + Open preview + {/if} + {/snippet} + + +
+ {/if} +{:else} + +{/if} diff --git a/frontend/src/lib/components/flows/map/FlowModuleSchemaItem.svelte b/frontend/src/lib/components/flows/map/FlowModuleSchemaItem.svelte index 356a1065c3..cfd666661e 100644 --- a/frontend/src/lib/components/flows/map/FlowModuleSchemaItem.svelte +++ b/frontend/src/lib/components/flows/map/FlowModuleSchemaItem.svelte @@ -41,6 +41,7 @@ import ModuleTest from '$lib/components/ModuleTest.svelte' import { getStepHistoryLoaderContext } from '$lib/components/stepHistoryLoader.svelte' import { aiModuleActionToBgColor } from '$lib/components/copilot/chat/flow/utils' + import type { FlowStatusModule, Job } from '$lib/gen' interface Props { selected?: boolean @@ -76,7 +77,12 @@ inputTransform?: Record | undefined onUpdateMock?: (mock: { enabled: boolean; return_value?: unknown }) => void onEditInput?: (moduleId: string, key: string) => void + flowJob?: Job | undefined + isOwner?: boolean enableTestRun?: boolean + type?: FlowStatusModule['type'] | undefined + darkMode?: boolean + skipped?: boolean } let { @@ -108,7 +114,11 @@ inputTransform, onUpdateMock, onEditInput, - enableTestRun = false + flowJob, + enableTestRun = false, + type, + darkMode, + skipped }: Props = $props() let pickableIds: Record | undefined = $state(undefined) @@ -193,6 +203,8 @@ const icon_render = $derived(icon) const action = $derived(getAiModuleAction(id)) + + let testRunDropdownOpen = $state(false) {#if deletable && id && editId} @@ -261,310 +273,320 @@ {/if} {/if} - - -
(hover = true)} - onmouseleave={() => (hover = false)} - onpointerdown={stopPropagation(preventDefault(() => dispatch('pointerdown')))} -> - {#if deletable} - - {/if} +
+ +
-
(hover = true)} + onmouseleave={() => (hover = false)} + onpointerdown={stopPropagation(preventDefault(() => dispatch('pointerdown')))} > - {#if retry} - -
- {#if retries}{retries}{/if} - -
- {#snippet text()} - Retries - {/snippet} -
+ {#if deletable} + {/if} - - {#if concurrency} - -
- -
- {#snippet text()} - Concurrency Limits - {/snippet} -
- {/if} - {#if cache} - -
- -
- {#snippet text()} - Cached - {/snippet} -
- {/if} - {#if earlyStop} - -
- -
- {#snippet text()} - {isTrigger ? 'Stop early if there are no new events' : 'Early stop/break'} - {/snippet} -
- {/if} - {#if skip} - -
- -
- {#snippet text()} - Skip - {/snippet} -
- {/if} - {#if suspend} - -
- -
- {#snippet text()} - Suspend - {/snippet} -
- {/if} - {#if sleep} - -
- -
- {#snippet text()} - Sleep - {/snippet} -
- {/if} - {#if mock?.enabled} - - - {#snippet text()} - Pinned - {/snippet} - - {/if} -
- -
-
+
- {#snippet icon()} - {@render icon_render?.()} - {/snippet} - - - {#if outputPickerVisible} - - {#snippet children({ allowCopy, isConnecting, selectConnection })} - - {/snippet} - - {/if} -
- - {#if deletable && !action} - {#if enableTestRun} -
- {#if (hover || selected) && outputPickerVisible} -
- {#if !testIsLoading} - - {:else} - - {/if} + {#if retry} + +
+ {#if retries}{retries}{/if} +
- {/if} -
- {/if} -
+ {#snippet text()} + Concurrency Limits + {/snippet} + + {/if} + {#if cache} + +
+ +
+ {#snippet text()} + Cached + {/snippet} +
+ {/if} + {#if earlyStop} + +
+ +
+ {#snippet text()} + {isTrigger ? 'Stop early if there are no new events' : 'Early stop/break'} + {/snippet} +
+ {/if} + {#if skip} + +
+ +
+ {#snippet text()} + Skip + {/snippet} +
+ {/if} + {#if suspend} + +
+ +
+ {#snippet text()} + Suspend + {/snippet} +
+ {/if} + {#if sleep} + +
+ +
+ {#snippet text()} + Sleep + {/snippet} +
+ {/if} + {#if mock?.enabled} + + + {#snippet text()} + Pinned + {/snippet} + + {/if} +
+ +
+ + {#snippet icon()} + {@render icon_render?.()} + {/snippet} + + + {#if outputPickerVisible} + + {#snippet children({ allowCopy, isConnecting, selectConnection })} + + {/snippet} + + {/if} +
+ + {#if deletable && !action} + + title="Delete" + onclick={stopPropagation( + preventDefault((event) => dispatch('delete', { id, type: modType })) + )} + > + + - {#if id !== 'preprocessor'} - - {/if} + onclick={stopPropagation(preventDefault((event) => dispatch('move')))} + title="Move" + > + + + {/if} - {#if (id && Object.values($flowInputsStore?.[id]?.flowStepWarnings || {}).length > 0) || Boolean(warningMessage)} -
- - {#snippet text()} -
    - {#if id} - {#each Object.values($flowInputsStore?.[id]?.flowStepWarnings || {}) as m} -
  • - {m.message} -
  • - {/each} - {/if} -
- {/snippet} -
x.type === 'error' - ) - ? 'border-red-600 text-red-600 bg-red-100 hover:bg-red-300' - : 'border-yellow-600 text-yellow-600 bg-yellow-100 hover:bg-yellow-300' - )} - > - -
-
-
+ {#if (id && Object.values($flowInputsStore?.[id]?.flowStepWarnings || {}).length > 0) || Boolean(warningMessage)} +
+ + {#snippet text()} +
    + {#if id} + {#each Object.values($flowInputsStore?.[id]?.flowStepWarnings || {}) as m} +
  • + {m.message} +
  • + {/each} + {/if} +
+ {/snippet} +
x.type === 'error' + ) + ? 'border-red-600 text-red-600 bg-red-100 hover:bg-red-300' + : 'border-yellow-600 text-yellow-600 bg-yellow-100 hover:bg-yellow-300' + )} + > + +
+
+
+ {/if} {/if} +
+ + {#if editMode && enableTestRun && flowJob?.type !== 'QueuedJob'} + +
(hover = true)} + onmouseleave={() => (hover = false)} + > + {#if (hover || selected || testRunDropdownOpen) && outputPickerVisible} +
+ {#if !testIsLoading} + + {:else} + + {/if} +
+ {/if} +
{/if}
diff --git a/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte b/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte index 9a1535c30b..61b9d7053a 100644 --- a/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte +++ b/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte @@ -13,7 +13,7 @@ pickFlow, insertNewPreprocessorModule } from '$lib/components/flows/flowStateUtils.svelte' - import type { FlowModule, ScriptLang } from '$lib/gen' + import type { FlowModule, Job, ScriptLang } from '$lib/gen' import { emptyFlowModuleState } from '../utils' import { dfs } from '../dfs' @@ -35,6 +35,8 @@ import { dfsByModule } from '../previousResults' import type { InlineScript, InsertKind } from '$lib/components/graph/graphBuilder.svelte' import { refreshStateStore } from '$lib/svelte5Utils.svelte' + import type { GraphModuleState } from '$lib/components/graph' + import { writable, type Writable } from 'svelte/store' import FlowStickyNode from './FlowStickyNode.svelte' import { getStepHistoryLoaderContext } from '$lib/components/stepHistoryLoader.svelte' @@ -49,9 +51,22 @@ workspace?: string | undefined onTestUpTo?: ((id: string) => void) | undefined onEditInput?: (moduleId: string, key: string) => void + localModuleStates?: Writable> aiChatOpen?: boolean showFlowAiButton?: boolean toggleAiChat?: () => void + isOwner?: boolean + onTestFlow?: () => void + isRunning?: boolean + onCancelTestFlow?: () => void + onOpenPreview?: () => void + onHideJobStatus?: () => void + individualStepTests?: boolean + flowJob?: Job | undefined + showJobStatus?: boolean + suspendStatus?: Writable> + onDelete?: (id: string) => void + flowHasChanged?: boolean } let { @@ -65,9 +80,22 @@ workspace = $workspaceStore, onTestUpTo, onEditInput, + localModuleStates = writable({}), aiChatOpen, showFlowAiButton, - toggleAiChat + toggleAiChat, + isOwner, + onTestFlow, + isRunning, + onCancelTestFlow, + onOpenPreview, + onHideJobStatus, + individualStepTests = false, + flowJob = undefined, + showJobStatus = false, + suspendStatus = writable({}), + onDelete, + flowHasChanged }: Props = $props() let flowTutorials: FlowTutorials | undefined = $state(undefined) @@ -77,6 +105,7 @@ const { triggersCount, triggersState } = getContext('TriggerContext') const { flowPropPickerConfig } = getContext('PropPickerContext') + export async function insertNewModuleAtIndex( modules: FlowModule[], index: number, @@ -223,6 +252,11 @@ let deleteCallback: (() => void) | undefined = $state(undefined) let dependents: Record = $state({}) + let graph: FlowGraphV2 | undefined = $state(undefined) + export function isNodeVisible(nodeId: string): boolean { + return graph?.isNodeVisible(nodeId) ?? false + } + function shouldRunTutorial(tutorialName: string, name: string, index: number) { return ( $tutorialsToDo.includes(index) && @@ -335,6 +369,7 @@
{ dependents = getDependentComponents(id, flowStore.val) const cb = () => { @@ -365,6 +407,7 @@ removeAtId(flowStore.val.value.modules, id) } refreshStateStore(flowStore) + onDelete?.(id) } if (Object.keys(dependents).length > 0) { @@ -547,6 +590,11 @@ module.mock = $state.snapshot(detail.mock) refreshStateStore(flowStore) }} + {onTestFlow} + {isRunning} + {onCancelTestFlow} + {onOpenPreview} + {onHideJobStatus} />
diff --git a/frontend/src/lib/components/flows/map/MapItem.svelte b/frontend/src/lib/components/flows/map/MapItem.svelte index 98182fa141..9aec1d13ad 100644 --- a/frontend/src/lib/components/flows/map/MapItem.svelte +++ b/frontend/src/lib/components/flows/map/MapItem.svelte @@ -2,7 +2,7 @@ import { Button } from '$lib/components/common' import LanguageIcon from '$lib/components/common/languageIcons/LanguageIcon.svelte' import IconedResourceType from '$lib/components/IconedResourceType.svelte' - import type { FlowModule } from '$lib/gen' + import type { FlowModule, FlowStatusModule, Job } from '$lib/gen' import { Building, Repeat, Square, ArrowDown, GitBranch } from 'lucide-svelte' import { createEventDispatcher, getContext } from 'svelte' import type { Writable } from 'svelte/store' @@ -17,6 +17,7 @@ } from '$lib/components/graph/graphBuilder.svelte' import { checkIfParentLoop } from '$lib/components/flows/utils' import type { FlowEditorContext } from '$lib/components/flows/types' + import { twMerge } from 'tailwind-merge' interface Props { moduleId: string @@ -45,6 +46,11 @@ mock: { enabled: boolean; return_value?: unknown } }) => void onEditInput?: (moduleId: string, key: string) => void + flowJob?: Job | undefined + isOwner?: boolean + type?: FlowStatusModule['type'] | undefined + darkMode?: boolean + skipped?: boolean } let { @@ -63,7 +69,12 @@ onSelect, onTestUpTo, onUpdateMock, - onEditInput + onEditInput, + flowJob, + isOwner = false, + type, + darkMode, + skipped }: Props = $props() const { selectedId } = getContext<{ @@ -107,12 +118,22 @@ {/if} {#if duration_ms} -
+
{msToSec(duration_ms)}s
{/if} {#if annotation && annotation != ''} -
+
{annotation}
{/if} @@ -160,6 +181,8 @@ alwaysShowOutputPicker={!mod.id.startsWith('subflow:')} loopStatus={{ type: 'self', flow: mod.value.type }} {onTestUpTo} + {type} + {darkMode} > {#snippet icon()}
@@ -181,6 +204,8 @@ {bgColor} {bgHoverColor} {onTestUpTo} + {type} + {darkMode} > {#snippet icon()}
@@ -202,6 +227,8 @@ {bgColor} {bgHoverColor} {onTestUpTo} + {type} + {darkMode} > {#snippet icon()}
@@ -246,7 +273,12 @@ inputTransform={mod.value.type !== 'identity' ? mod.value.input_transforms : undefined} {onTestUpTo} {onEditInput} + {flowJob} + {isOwner} enableTestRun + {type} + {darkMode} + {skipped} > {#snippet icon()}
diff --git a/frontend/src/lib/components/flows/map/VirtualItem.svelte b/frontend/src/lib/components/flows/map/VirtualItem.svelte index f524243f18..25dfcad15a 100644 --- a/frontend/src/lib/components/flows/map/VirtualItem.svelte +++ b/frontend/src/lib/components/flows/map/VirtualItem.svelte @@ -11,6 +11,8 @@ getAiModuleAction } from '$lib/components/copilot/chat/flow/ModuleAcceptReject.svelte' import { aiModuleActionToBgColor } from '$lib/components/copilot/chat/flow/utils' + import FlowGraphPreviewButton from './FlowGraphPreviewButton.svelte' + import type { Job } from '$lib/gen' interface Props { label?: string | undefined @@ -25,13 +27,24 @@ preLabel?: string | undefined inputJson?: Object | undefined prefix?: string - alwaysPluggable?: boolean cache?: boolean earlyStop?: boolean editMode?: boolean icon?: import('svelte').Snippet onUpdateMock?: (mock: { enabled: boolean; return_value?: unknown }) => void onEditInput?: (moduleId: string, key: string) => void + onTestFlow?: () => void + isRunning?: boolean + onCancelTestFlow?: () => void + onOpenPreview?: () => void + onHideJobStatus?: () => void + individualStepTests?: boolean + nodeKind?: 'input' | 'result' + job?: Job + type?: string + showJobStatus?: boolean + darkMode?: boolean + flowHasChanged?: boolean } let { @@ -47,20 +60,43 @@ preLabel = undefined, inputJson = undefined, prefix = '', - alwaysPluggable = false, + nodeKind, cache = false, earlyStop = false, editMode = false, icon, onUpdateMock, - onEditInput + onEditInput, + onTestFlow, + isRunning, + onCancelTestFlow, + onOpenPreview, + onHideJobStatus, + individualStepTests = false, + job, + showJobStatus = false, + darkMode = false, + flowHasChanged = false }: Props = $props() const outputPickerVisible = $derived( - (alwaysPluggable || (inputJson && Object.keys(inputJson).length > 0)) && editMode + (nodeKind || (inputJson && Object.keys(inputJson).length > 0)) && editMode ) let action = $derived(label === 'Input' ? getAiModuleAction(label) : undefined) + let hoverButton = $state(false) + + const outputType = $derived( + showJobStatus + ? job?.type === 'QueuedJob' + ? 'InProgress' + : job?.type === 'CompletedJob' + ? job.success + ? 'Success' + : 'Failure' + : undefined + : undefined + ) {#snippet children({ allowCopy, isConnecting, selectConnection })} {/snippet} {/if}
+
{#if cache} @@ -162,4 +207,36 @@ {/if}
{/snippet} + {#snippet previewButton()} + {#if nodeKind === 'input'} + +
{ + hoverButton = true + }} + onmouseleave={() => { + hoverButton = false + }} + > + {#if outputPickerVisible} +
+ +
+ {/if} +
+ {/if} + {/snippet} diff --git a/frontend/src/lib/components/flows/map/VirtualItemWrapper.svelte b/frontend/src/lib/components/flows/map/VirtualItemWrapper.svelte index 92c7761ba0..bbbb4e4536 100644 --- a/frontend/src/lib/components/flows/map/VirtualItemWrapper.svelte +++ b/frontend/src/lib/components/flows/map/VirtualItemWrapper.svelte @@ -15,6 +15,7 @@ children?: import('svelte').Snippet<[any]> outputPickerVisible?: boolean className?: string + previewButton?: import('svelte').Snippet } let { @@ -27,7 +28,8 @@ bgHoverColor = '', children, outputPickerVisible = false, - className + className, + previewButton }: Props = $props() const dispatch = createEventDispatcher<{ @@ -43,35 +45,39 @@ let hover: boolean = $state(false) - - -
{ - if (selectable) { - dispatch('select', id || label || '') - } - }} - onmouseenter={() => { - hover = true - }} - onmouseleave={() => { - hover = false - }} - title={label ? label + ' ' : ''} - id={`flow-editor-virtual-${encodeURIComponent(label || label || '')}`} -> +
+ +
{ + if (selectable) { + dispatch('select', id || label || '') + } + }} + onmouseenter={() => { + hover = true + }} + onmouseleave={() => { + hover = false + }} + title={label ? label + ' ' : ''} + id={`flow-editor-virtual-${encodeURIComponent(label || label || '')}`} > +
+
+ {@render children?.({ hover })}
- {@render children?.({ hover })} + + {@render previewButton?.()}
diff --git a/frontend/src/lib/components/flows/propPicker/OutputPicker.svelte b/frontend/src/lib/components/flows/propPicker/OutputPicker.svelte index b9f3de6798..4f4552e991 100644 --- a/frontend/src/lib/components/flows/propPicker/OutputPicker.svelte +++ b/frontend/src/lib/components/flows/propPicker/OutputPicker.svelte @@ -1,12 +1,15 @@
diff --git a/frontend/src/lib/components/flows/propPicker/OutputPickerInner.svelte b/frontend/src/lib/components/flows/propPicker/OutputPickerInner.svelte index 9786e8b2db..5668d1e544 100644 --- a/frontend/src/lib/components/flows/propPicker/OutputPickerInner.svelte +++ b/frontend/src/lib/components/flows/propPicker/OutputPickerInner.svelte @@ -68,6 +68,7 @@ selectionId?: string initial?: boolean onResetInitial?: () => void + customEmptyJobMessage?: string } let { @@ -101,7 +102,8 @@ onUpdateMock, onEditInput, selectionId, - initial + initial, + customEmptyJobMessage }: Props = $props() type SelectedJob = @@ -701,7 +703,7 @@ {:else if !job}

- Test this step to see results{#if !disableMock} + {customEmptyJobMessage ?? 'Test this step to see results'}{#if !disableMock} {' or'}

{:else} - + { document.dispatchEvent(new Event('focus')) @@ -489,7 +535,7 @@ nodesDraggable={false} --background-color={false} > -
+
{#if download} import { useSvelteFlow } from '@xyflow/svelte' import { untrack } from 'svelte' + import { NODE } from './util' - let { width } = $props() + let { width, nodes, height } = $props() const { setViewport, getViewport } = useSvelteFlow() $effect(() => { @@ -21,4 +22,42 @@ x: viewport.x + diff / 2 }) } + + /** + * Check if a node is fully visible in the current viewport with margin + * @param nodeId - The ID of the node to check + * @returns boolean - true if node is fully visible with 20px margin, false otherwise + */ + export function isNodeVisible(nodeId: string): boolean { + const node = nodes.find((n) => n.id === nodeId) + const viewport = getViewport() + if (!node || !viewport) return false + + const { x, y, zoom } = viewport + const nodeX = node.position.x + const nodeY = node.position.y + + // 20px margin scaled by zoom level + const margin = 20 / zoom + + // Calculate viewport bounds with margin + const viewportLeft = -x / zoom + margin + const viewportTop = -y / zoom + margin + const viewportRight = viewportLeft + width / zoom - 2 * margin + const viewportBottom = viewportTop + height / zoom - 2 * margin + + // Calculate node bounds + const nodeLeft = nodeX + const nodeTop = nodeY + const nodeRight = nodeX + NODE.width + const nodeBottom = nodeY + NODE.height + + // Node is visible only if it's completely within viewport bounds with margin + return ( + nodeLeft > viewportLeft && + nodeRight < viewportRight && + nodeTop > viewportTop && + nodeBottom < viewportBottom + ) + } diff --git a/frontend/src/lib/components/graph/graphBuilder.svelte.ts b/frontend/src/lib/components/graph/graphBuilder.svelte.ts index 4a1032ff01..5e7d750640 100644 --- a/frontend/src/lib/components/graph/graphBuilder.svelte.ts +++ b/frontend/src/lib/components/graph/graphBuilder.svelte.ts @@ -1,9 +1,10 @@ -import type { FlowModule, RawScript, Script } from '$lib/gen' +import type { FlowModule, Job, RawScript, Script } from '$lib/gen' import { type Edge } from '@xyflow/svelte' import { getDependeeAndDependentComponents } from '../flows/flowExplorer' import { dfsByModule } from '../flows/previousResults' import { defaultIfEmptyString } from '$lib/utils' import type { GraphModuleState } from './model' +import type { Writable } from 'svelte/store' export type InsertKind = | 'script' @@ -54,6 +55,10 @@ export type GraphEventHandlers = { updateMock: (detail: { mock: FlowModule['mock']; id: string }) => void testUpTo: (id: string) => void editInput: (moduleId: string, key: string) => void + testFlow: () => void + cancelTestFlow: () => void + openPreview: () => void + hideJobStatus: () => void } export type SimplifiableFlow = { simplifiedFlow: boolean } @@ -102,6 +107,11 @@ export type InputN = { cache: boolean earlyStop: boolean editMode: boolean + isRunning: boolean + individualStepTests: boolean + flowJob: Job | undefined + showJobStatus: boolean + flowHasChanged: boolean } } @@ -117,6 +127,8 @@ export type ModuleN = { flowModuleStates: Record | undefined insertable: boolean editMode: boolean + flowJob: Job | undefined + isOwner: boolean } } @@ -174,6 +186,9 @@ export type ResultN = { data: { success: boolean | undefined eventHandlers: GraphEventHandlers + editMode: boolean + job: Job | undefined + showJobStatus: boolean } } @@ -283,6 +298,13 @@ export function graphBuilder( cache: boolean earlyStop: boolean editMode: boolean + isOwner: boolean + isRunning: boolean + individualStepTests: boolean + flowJob: Job | undefined + showJobStatus: boolean + suspendStatus: Writable> + flowHasChanged: boolean }, failureModule: FlowModule | undefined, preprocessorModule: FlowModule | undefined, @@ -333,7 +355,9 @@ export function graphBuilder( moving: moving, flowModuleStates: extra.flowModuleStates, insertable: extra.insertable, - editMode: extra.editMode + editMode: extra.editMode, + isOwner: extra.isOwner, + flowJob: extra.flowJob }, type: 'module' }) @@ -433,7 +457,12 @@ export function graphBuilder( disableAi: extra.disableAi, cache: extra.cache, earlyStop: extra.earlyStop, - editMode: extra.editMode + editMode: extra.editMode, + isRunning: extra.isRunning, + individualStepTests: extra.individualStepTests, + flowJob: extra.flowJob, + showJobStatus: extra.showJobStatus, + flowHasChanged: extra.flowHasChanged } } @@ -467,7 +496,10 @@ export function graphBuilder( id: 'result', data: { eventHandlers: eventHandlers, - success: success + success: success, + editMode: extra.editMode, + job: extra.flowJob, + showJobStatus: extra.showJobStatus }, type: 'result' } diff --git a/frontend/src/lib/components/graph/renderers/edges/BaseEdge.svelte b/frontend/src/lib/components/graph/renderers/edges/BaseEdge.svelte index eff59e5ee5..bb7b787668 100644 --- a/frontend/src/lib/components/graph/renderers/edges/BaseEdge.svelte +++ b/frontend/src/lib/components/graph/renderers/edges/BaseEdge.svelte @@ -1,12 +1,16 @@ - {#if data?.insertable && !$useDataflow && !data?.moving} + {#if data?.insertable && !$useDataflow && !data?.moving && !waitingForEvents}
{/if} + + {#if waitingForEvents && data.flowJob && data.flowJob.type === 'QueuedJob'} +
+ +
+ . + . + . +
+
+
+ {#if data?.flowJob && data.flowJob.flow_status?.modules?.[data.flowJob.flow_status?.step]?.type === 'WaitingForEvents'} + + {:else if $suspendStatus && Object.keys($suspendStatus).length > 0} +
+ {#each Object.values($suspendStatus) as suspendCount (suspendCount.job.id)} + + {/each} +
+ {/if} +
+ {/if} @@ -140,4 +195,28 @@ /* if you have an interactive element, set pointer-events: all */ pointer-events: all; } + + .dot { + opacity: 0; + animation: dotFade 1.5s infinite; + letter-spacing: 1px; + } + + .dot:nth-child(2) { + animation-delay: 0.5s; + } + + .dot:nth-child(3) { + animation-delay: 1s; + } + + @keyframes dotFade { + 0%, + 100% { + opacity: 0; + } + 50% { + opacity: 1; + } + } diff --git a/frontend/src/lib/components/graph/renderers/nodes/InputNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/InputNode.svelte index 45f4748cb2..ff5baf6308 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/InputNode.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/InputNode.svelte @@ -72,11 +72,29 @@ }} inputJson={topFlowInput} prefix="flow_input" - alwaysPluggable + nodeKind="input" cache={data.cache} earlyStop={data.earlyStop} editMode={data.editMode} onEditInput={data.eventHandlers.editInput} + onTestFlow={() => { + data.eventHandlers.testFlow() + }} + isRunning={data.isRunning} + onCancelTestFlow={() => { + data.eventHandlers.cancelTestFlow() + }} + onOpenPreview={() => { + data.eventHandlers.openPreview() + }} + onHideJobStatus={() => { + data.eventHandlers.hideJobStatus() + }} + individualStepTests={data.individualStepTests} + job={data.flowJob} + showJobStatus={data.showJobStatus} + flowHasChanged={data.flowHasChanged} + {darkMode} /> {/snippet} diff --git a/frontend/src/lib/components/graph/renderers/nodes/ModuleNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/ModuleNode.svelte index db58a3a713..2bc9704692 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/ModuleNode.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/ModuleNode.svelte @@ -65,8 +65,18 @@ '/' + (moduleState?.iteration_total ?? '?') : ''} - bgColor={getStateColor(type, darkMode, true, moduleState?.skipped)} - bgHoverColor={getStateHoverColor(type, darkMode, true, moduleState?.skipped)} + bgColor={getStateColor( + data.editMode ? undefined : type, + darkMode, + true, + moduleState?.skipped + )} + bgHoverColor={getStateHoverColor( + data.editMode ? undefined : type, + darkMode, + true, + moduleState?.skipped + )} moving={data.moving} duration_ms={moduleState?.duration_ms} retries={moduleState?.retries} @@ -94,6 +104,11 @@ data.eventHandlers.updateMock(detail) }} onEditInput={data.eventHandlers.editInput} + flowJob={data.flowJob} + isOwner={data.isOwner} + {type} + {darkMode} + skipped={moduleState?.skipped} />
diff --git a/frontend/src/lib/components/graph/renderers/nodes/ResultNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/ResultNode.svelte index cadad9f588..b22150aa3e 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/ResultNode.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/ResultNode.svelte @@ -15,6 +15,10 @@ const { selectedId } = getContext<{ selectedId: Writable }>('FlowGraphContext') + + const type = $derived( + data.success == undefined ? undefined : data.success ? 'Success' : 'Failure' + ) @@ -25,17 +29,17 @@ selectable={true} selected={$selectedId === 'Result'} hideId={true} - bgColor={getStateColor( - data.success == undefined ? undefined : data.success ? 'Success' : 'Failure', - darkMode - )} - bgHoverColor={getStateHoverColor( - data.success == undefined ? undefined : data.success ? 'Success' : 'Failure', - darkMode - )} + bgColor={getStateColor(type, darkMode)} + bgHoverColor={getStateHoverColor(type, darkMode)} on:select={(e) => { setTimeout(() => data?.eventHandlers?.select(e.detail)) }} + nodeKind="result" + editMode={data.editMode} + job={data.job} + {type} + showJobStatus={data.showJobStatus} + {darkMode} /> {/snippet} diff --git a/frontend/src/lib/components/modulesTest.svelte.ts b/frontend/src/lib/components/modulesTest.svelte.ts new file mode 100644 index 0000000000..a8f3b938a5 --- /dev/null +++ b/frontend/src/lib/components/modulesTest.svelte.ts @@ -0,0 +1,17 @@ +import type { Job } from '$lib/gen' + +type moduleTestState = { + loading: boolean + cancel?: () => Promise + testJob?: Job +} + +export class ModulesTestStates { + states: Record = $state({}) + runTestCb?: (moduleId: string) => void + + constructor(runTestCb?: (moduleId: string) => void) { + this.states = {} + this.runTestCb = runTestCb + } +} diff --git a/frontend/src/lib/components/preview/FlowPreviewStatus.svelte b/frontend/src/lib/components/preview/FlowPreviewStatus.svelte index b21bde0038..ff10a65c8b 100644 --- a/frontend/src/lib/components/preview/FlowPreviewStatus.svelte +++ b/frontend/src/lib/components/preview/FlowPreviewStatus.svelte @@ -4,15 +4,24 @@ import JobStatus from '../JobStatus.svelte' import { ExternalLinkIcon } from 'lucide-svelte' - import type { FlowStatusViewerContext } from '../graph' - import { getContext } from 'svelte' import { truncateRev } from '$lib/utils' - export let job: QueuedJob | CompletedJob + import { twMerge } from 'tailwind-merge' - let { hideJobId } = getContext('FlowStatusViewer') + interface Props { + job: QueuedJob | CompletedJob + hideJobId?: boolean + extra?: import('svelte').Snippet + } + + let { job, hideJobId = false, extra }: Props = $props() -
+
{#if job && !hideJobId}
@@ -29,4 +38,5 @@
{/if} + {@render extra?.()}
diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 4d6bf63f16..dafff46121 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -379,6 +379,7 @@ export function pointerDownOutside( event.stopPropagation() } node.dispatchEvent(new CustomEvent('pointerdown_outside', { detail: event })) + if (typeof options === 'object') options.onClickOutside?.(event) return false } } diff --git a/frontend/src/routes/flows/dev/+page.svelte b/frontend/src/routes/flows/dev/+page.svelte index 9c16ff9a66..1180a3ff52 100644 --- a/frontend/src/routes/flows/dev/+page.svelte +++ b/frontend/src/routes/flows/dev/+page.svelte @@ -25,6 +25,7 @@ import type { PickableProperties } from '$lib/components/flows/previousResults' import { Triggers } from '$lib/components/triggers/triggers.svelte' import { TestSteps } from '$lib/components/flows/testSteps.svelte' + import { ModulesTestStates } from '$lib/components/modulesTest.svelte' let token = $page.url.searchParams.get('wm_token') ?? undefined let workspace = $page.url.searchParams.get('workspace') ?? undefined @@ -106,7 +107,9 @@ editPanelSize: undefined, payloadData: undefined }), - currentEditor: writable(undefined) + currentEditor: writable(undefined), + modulesTestStates: new ModulesTestStates(), + outputPickerOpenFns: {} }) setContext('PropPickerContext', { flowPropPickerConfig: writable(undefined),