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 a39c70a920.

* 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 939e9dbaaf.

* 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>
This commit is contained in:
Guilhem
2025-07-09 17:24:03 +01:00
committed by GitHub
parent 9197e79491
commit cec2fac7e4
35 changed files with 1953 additions and 820 deletions
+114 -7
View File
@@ -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<TriggersCount | undefined>(undefined)
const modulesTestStates = new ModulesTestStates((moduleId) => {
// Update the derived store with test job states
delete $derivedModuleStates[moduleId]
testModuleId = moduleId
showJobStatus = false
})
const outputPickerOpenFns: Record<string, () => void> = $state({})
setContext<TriggerContext>('TriggerContext', {
triggersCount: triggersCount,
simplifiedPoll: writable(false),
@@ -469,7 +484,9 @@
editPanelSize: undefined,
payloadData: undefined
}),
currentEditor: writable(undefined)
currentEditor: writable(undefined),
modulesTestStates,
outputPickerOpenFns
})
setContext<PropPickerContext>('PropPickerContext', {
flowPropPickerConfig: writable<FlowPropPickerConfig | undefined>(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<Record<string, GraphModuleState>> = $derived(
flowPreviewButtons?.getLocalModuleStates() ?? writable({})
)
const localDurationStatuses: Writable<Record<string, DurationStatus>> = $derived(
flowPreviewButtons?.getLocalDurationStatuses() ?? writable({})
)
const suspendStatus: Writable<Record<string, { job: Job; nb: number }>> = $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<Record<string, GraphModuleState>>({})
$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
)
</script>
<svelte:window onkeydown={onKeyDown} />
@@ -691,18 +770,40 @@
{/if}
</div>
<div class="flex justify-center pt-1 z-50 absolute right-2 top-2 gap-2">
<FlowPreviewButtons bind:this={flowPreviewButtons} />
<div class="flex justify-center pt-1 z-50 absolute -translate-x-[100%] right-2 top-2 gap-2">
<FlowPreviewButtons
bind:this={flowPreviewButtons}
{onJobDone}
onRunPreview={() => {
localModuleStates.set({})
showJobStatus = true
}}
/>
</div>
<Splitpanes horizontal class="h-full max-h-screen grow">
<Pane size={67}>
{#if flowStore.val?.value?.modules}
<div id="flow-editor"></div>
<FlowModuleSchemaMap
bind:this={flowModuleSchemaMap}
disableAi
disableTutorials
smallErrorHandler={true}
disableStaticInputs
localModuleStates={derivedModuleStates}
onTestUpTo={flowPreviewButtons?.testUpTo}
isOwner={flowPreviewButtons?.getIsOwner()}
onTestFlow={flowPreviewButtons?.runPreview}
isRunning={flowPreviewButtons?.getIsRunning()}
onCancelTestFlow={flowPreviewButtons?.cancelTest}
onOpenPreview={flowPreviewButtons?.openPreview}
onHideJobStatus={resetModulesStates}
{individualStepTests}
flowJob={job}
{showJobStatus}
onDelete={(id) => {
delete $derivedModuleStates[id]
}}
/>
{:else}
<div class="text-red-400 mt-20">Missing flow modules</div>
@@ -722,6 +823,12 @@
flowPreviewButtons?.openPreview()
}
}}
onTestFlow={flowPreviewButtons?.runPreview}
{job}
isOwner={flowPreviewButtons?.getIsOwner()}
{localDurationStatuses}
{suspendStatus}
onOpenDetails={flowPreviewButtons?.openPreview}
/>
{/key}
</Pane>
+111 -10
View File
@@ -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<boolean>(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<string, () => void> = $state({})
let flowEditor: FlowEditor | undefined = $state(undefined)
setContext<FlowEditorContext>('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<string, boolean> = $state({})
let highlightArg: Record<string, string | undefined> = $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<Record<string, GraphModuleState>> = $derived(
flowPreviewContent?.getLocalModuleStates() ?? writable({})
)
const localDurationStatuses: Writable<Record<string, DurationStatus>> = $derived(
flowPreviewContent?.getLocalDurationStatuses() ?? writable({})
)
const suspendStatus: Writable<Record<string, { job: Job; nb: number }>> = $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<Record<string, GraphModuleState>>({})
$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())
</script>
<svelte:window onkeydown={onKeyDown} />
@@ -1069,8 +1151,13 @@
captureOn.set(true)
showCaptureHint.set(true)
}}
{onJobDone}
bind:this={flowPreviewButtons}
{loading}
onRunPreview={() => {
localModuleStates.set({})
showJobStatus = true
}}
/>
<Button
loading={loadingDraft}
@@ -1097,6 +1184,7 @@
<!-- metadata -->
{#if $flowStateStore}
<FlowEditor
bind:this={flowEditor}
{disabledFlowInputs}
disableAi={disableAi || customUi?.stepInputs?.ai == false}
disableSettings={customUi?.settingsPanel === false}
@@ -1115,8 +1203,8 @@
previewArgsStore.val = JSON.parse(JSON.stringify(e.detail))
flowPreviewButtons?.openPreview(true)
}}
onTestUpTo={() => {
flowPreviewButtons?.testUpTo()
onTestUpTo={(id) => {
flowPreviewButtons?.testUpTo(id)
}}
{savedFlow}
onDeployTrigger={handleDeployTrigger}
@@ -1136,9 +1224,22 @@
aiChatOpen={aiChatManager.open}
showFlowAiButton={!disableAi && customUi?.topBar?.aiBuilder != false}
toggleAiChat={() => aiChatManager.toggleOpen()}
onRunPreview={() => {
flowPreviewButtons?.openPreview(true)
onOpenPreview={flowPreviewButtons?.openPreview}
localModuleStates={derivedModuleStates}
isOwner={flowPreviewContent?.getIsOwner()}
onTestFlow={flowPreviewButtons?.runPreview}
isRunning={flowPreviewContent?.getIsRunning()}
onCancelTestFlow={flowPreviewContent?.cancelTest}
onHideJobStatus={resetModulesStates}
{individualStepTests}
{job}
{localDurationStatuses}
{suspendStatus}
{showJobStatus}
onDelete={(id) => {
delete $derivedModuleStates[id]
}}
{flowHasChanged}
/>
{:else}
<CenteredPage>Loading...</CenteredPage>
@@ -1,4 +1,6 @@
<script lang="ts">
import { run, stopPropagation } from 'svelte/legacy'
import { type Job, JobService, type RestartedFrom, type OpenFlow } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { Badge, Button } from './common'
@@ -24,33 +26,56 @@
import { aiChatManager } from './copilot/chat/AIChatManager.svelte'
import { stateSnapshot } from '$lib/svelte5Utils.svelte'
export let previewMode: 'upTo' | 'whole'
export let open: boolean
export let preventEscape = false
interface Props {
previewMode: 'upTo' | 'whole'
open: boolean
preventEscape?: boolean
jobId?: string | undefined
job?: Job | undefined
initial?: boolean
selectedJobStep?: string | undefined
selectedJobStepIsTopLevel?: boolean | undefined
selectedJobStepType?: 'single' | 'forloop' | 'branchall'
rightColumnSelect?: 'timeline' | 'node_status' | 'node_definition' | 'user_states'
branchOrIterationN?: number
scrollTop?: number
localModuleStates?: Writable<Record<string, GraphModuleState>>
localDurationStatuses?: Writable<Record<string, DurationStatus>>
onRunPreview?: () => void
render?: boolean
onJobDone?: () => void
upToId?: string | undefined
}
export let jobId: string | undefined = undefined
export let job: Job | undefined = undefined
export let selectedJobStep: string | undefined = undefined
export let selectedJobStepIsTopLevel: boolean | undefined = undefined
export let selectedJobStepType: 'single' | 'forloop' | 'branchall' = 'single'
export let rightColumnSelect: 'timeline' | 'node_status' | 'node_definition' | 'user_states' =
'timeline'
export let branchOrIterationN: number = 0
export let scrollTop: number = 0
export let localModuleStates: Writable<Record<string, GraphModuleState>> = writable({})
export let localDurationStatuses: Writable<Record<string, DurationStatus>> = writable({})
let {
previewMode = $bindable(),
open,
preventEscape = $bindable(false),
jobId = $bindable(undefined),
job = $bindable(undefined),
initial = $bindable(false),
selectedJobStep = $bindable(undefined),
selectedJobStepIsTopLevel = $bindable(undefined),
selectedJobStepType = $bindable('single'),
rightColumnSelect = $bindable('timeline'),
branchOrIterationN = $bindable(0),
scrollTop = $bindable(0),
localModuleStates = $bindable(writable({})),
localDurationStatuses = $bindable(writable({})),
onRunPreview,
render = false,
onJobDone,
upToId = undefined
}: Props = $props()
let restartBranchNames: [number, string][] = []
let isRunning: boolean = false
let jobProgressReset: () => void
let jsonView: boolean = false
let jsonEditor: JsonInputs
let schemaHeight = 0
let isValid: boolean = true
let isRunning: boolean = $state(false)
let jsonView: boolean = $state(false)
let jsonEditor: JsonInputs | undefined = $state(undefined)
let schemaHeight = $state(0)
let isValid: boolean = $state(true)
let suspendStatus: Writable<Record<string, { job: Job; nb: number }>> = $state(writable({}))
let isOwner: boolean = $state(false)
export function test() {
renderCount++
@@ -67,14 +92,14 @@
fakeInitialPath,
customUi,
executionCount
} = getContext<FlowEditorContext>('FlowEditorContext')
} = $state(getContext<FlowEditorContext>('FlowEditorContext'))
const dispatch = createEventDispatcher()
let renderCount: number = $state(0)
let schemaFormWithArgPicker: SchemaFormWithArgPicker | undefined = $state(undefined)
let currentJobId: string | undefined = $state(undefined)
let stepHistoryLoader = getStepHistoryLoaderContext()
let renderCount: number = 0
let schemaFormWithArgPicker: SchemaFormWithArgPicker | undefined = undefined
let currentJobId: string | undefined = undefined
let flowProgressBar: FlowProgressBar | undefined = $state(undefined)
function extractFlow(previewMode: 'upTo' | 'whole'): OpenFlow {
const previewFlow = aiChatManager.flowAiChatHelpers?.getPreviewFlow()
@@ -83,7 +108,7 @@
} else {
const flow = previewFlow ?? stateSnapshot(flowStore).val
const idOrders = dfs(flow.value.modules, (x) => x.id)
let upToIndex = idOrders.indexOf($selectedId)
let upToIndex = idOrders.indexOf(upToId ?? $selectedId)
if (upToIndex != -1) {
flow.value.modules = sliceModules(flow.value.modules, upToIndex, idOrders)
@@ -92,7 +117,7 @@
}
}
let lastPreviewFlow: undefined | string = undefined
let lastPreviewFlow: undefined | string = $state(undefined)
export async function runPreview(
args: Record<string, any>,
restartedFrom: RestartedFrom | undefined
@@ -102,7 +127,7 @@
}
try {
lastPreviewFlow = JSON.stringify(flowStore.val)
jobProgressReset()
flowProgressBar?.reset()
const newFlow = extractFlow(previewMode)
jobId = await runFlowPreview(args, newFlow, $pathStore, restartedFrom)
isRunning = true
@@ -110,6 +135,7 @@
savedArgs = previewArgs.val
inputSelected = undefined
}
onRunPreview?.()
} catch (e) {
sendUserToast('Could not run preview', true, undefined, e.toString())
isRunning = false
@@ -160,8 +186,8 @@
}
}
let savedArgs = previewArgs.val
let inputSelected: 'captures' | 'history' | 'saved' | undefined = undefined
let savedArgs = $state(previewArgs.val)
let inputSelected: 'captures' | 'history' | 'saved' | undefined = $state(undefined)
async function selectInput(input, type?: 'captures' | 'history' | 'saved' | undefined) {
if (!input) {
previewArgs.val = savedArgs
@@ -181,297 +207,335 @@
renderCount++
}
$: if (job?.type === 'CompletedJob') {
isRunning = false
}
$: selectedJobStep !== undefined && onSelectedJobStepChange()
let scrollableDiv: HTMLDivElement | undefined = undefined
function handleScroll() {
scrollTop = scrollableDiv?.scrollTop ?? 0
}
$: scrollableDiv && onScrollableDivChange()
function onScrollableDivChange() {
if (scrollTop != 0 && scrollableDiv) {
scrollableDiv.scrollTop = scrollTop
}
}
run(() => {
selectedJobStep !== undefined && onSelectedJobStepChange()
})
run(() => {
scrollableDiv && onScrollableDivChange()
})
export async function cancelTest() {
isRunning = false
try {
jobId &&
(await JobService.cancelQueuedJob({
workspace: $workspaceStore ?? '',
id: jobId,
requestBody: {}
}))
} catch {}
}
export function getLocalModuleStates() {
return localModuleStates
}
export function getLocalDurationStatuses() {
return localDurationStatuses
}
export function getSuspendStatus() {
return suspendStatus
}
export function getIsRunning() {
return isRunning
}
export function getIsOwner() {
return isOwner
}
export function getJob() {
return job
}
export function flowHasChanged() {
return !!lastPreviewFlow && JSON.stringify(flowStore.val) != lastPreviewFlow
}
</script>
<svelte:window on:keydown={onKeyDown} />
<svelte:window onkeydown={onKeyDown} />
<div class="flex flex-col space-y-2 h-screen bg-surface px-4 py-2 w-full" id="flow-preview-content">
<div class="flex flex-row w-full items-center gap-x-2">
<div class="w-8">
<Button
on:click={() => dispatch('close')}
startIcon={{ icon: X }}
iconOnly
size="sm"
color="light"
btnClasses="hover:bg-surface-hover bg-surface-secondaryw-8 h-8 rounded-full p-0"
/>
{#if render}
<div class="flex flex-row w-full items-center gap-x-2">
<div class="w-8">
<Button
on:click={() => dispatch('close')}
startIcon={{ icon: X }}
iconOnly
size="sm"
color="light"
btnClasses="hover:bg-surface-hover bg-surface-secondaryw-8 h-8 rounded-full p-0"
/>
</div>
{#if isRunning}
<div class="mx-auto">
<Button
color="red"
on:click={async () => {
cancelTest()
}}
size="sm"
btnClasses="w-full max-w-lg"
loading={true}
clickableWhileLoading
>
Cancel
</Button>
</div>
{:else}
<div class="grow justify-center flex flex-row gap-4">
{#if jobId !== undefined && selectedJobStep !== undefined && selectedJobStepIsTopLevel && aiChatManager.flowAiChatHelpers?.getModuleAction(selectedJobStep) !== 'removed'}
{#if selectedJobStepType == 'single'}
<Button
size="xs"
color="light"
variant="border"
title={`Re-start this flow from step ${selectedJobStep} (included).`}
on:click={() => {
runPreview(previewArgs.val, {
flow_job_id: jobId,
step_id: selectedJobStep,
branch_or_iteration_n: 0
})
}}
startIcon={{ icon: Play }}
>
Re-start from
<Badge baseClass="ml-1" color="indigo">
{selectedJobStep}
</Badge>
</Button>
{:else}
<Popover
floatingConfig={{ strategy: 'absolute', placement: 'bottom-start' }}
contentClasses="p-4"
>
{#snippet button()}
<Button
title={`Re-start this flow from step ${selectedJobStep} (included).`}
variant="border"
color="blue"
startIcon={{ icon: RefreshCw }}
on:click={() => {
runPreview(previewArgs.val, {
flow_job_id: jobId,
step_id: selectedJobStep,
branch_or_iteration_n: 0
})
}}
nonCaptureEvent={true}
>
Re-start from
<Badge baseClass="ml-1" color="indigo">
{selectedJobStep}
</Badge>
</Button>
{/snippet}
{#snippet content()}
<label class="block text-primary p-4">
<div class="pb-1 text-sm text-secondary"
>{selectedJobStepType == 'forloop'
? 'From iteration #:'
: 'From branch:'}</div
>
<div class="flex w-full">
{#if selectedJobStepType === 'forloop'}
<input
type="number"
min="0"
bind:value={branchOrIterationN}
class="!w-32 grow"
onclick={stopPropagation(() => {})}
/>
{:else}
<select
bind:value={branchOrIterationN}
class="!w-32 grow"
onclick={stopPropagation(() => {})}
>
{#each restartBranchNames as [branchIdx, branchName]}
<option value={branchIdx}>{branchName}</option>
{/each}
</select>
{/if}
<Button
size="xs"
color="blue"
buttonType="button"
btnClasses="!p-1 !w-[34px] !ml-1"
aria-label="Restart flow"
on:click|once={() => {
runPreview(previewArgs.val, {
flow_job_id: jobId,
step_id: selectedJobStep,
branch_or_iteration_n: branchOrIterationN
})
}}
>
<ArrowRight size={18} />
</Button>
</div>
</label>
{/snippet}
</Popover>
{/if}
{/if}
<Button
variant="contained"
startIcon={{ icon: isRunning ? RefreshCw : Play }}
color="dark"
size="sm"
btnClasses="w-full max-w-lg"
on:click={() => runPreview(previewArgs.val, undefined)}
id="flow-editor-test-flow-drawer"
shortCut={{ Icon: CornerDownLeft }}
>
{#if previewMode == 'upTo'}
Test up to
<Badge baseClass="ml-1" color="indigo">
{$selectedId}
</Badge>
{:else}
Test flow
{/if}
</Button>
</div>
{/if}
</div>
{#if isRunning}
<div class="mx-auto">
<Button
color="red"
on:click={async () => {
isRunning = false
try {
jobId &&
(await JobService.cancelQueuedJob({
workspace: $workspaceStore ?? '',
id: jobId,
requestBody: {}
}))
} catch {}
}}
size="sm"
btnClasses="w-full max-w-lg"
loading={true}
clickableWhileLoading
>
Cancel
</Button>
</div>
{:else}
<div class="grow justify-center flex flex-row gap-4">
{#if jobId !== undefined && selectedJobStep !== undefined && selectedJobStepIsTopLevel && aiChatManager.flowAiChatHelpers?.getModuleAction(selectedJobStep) !== 'removed'}
{#if selectedJobStepType == 'single'}
<Button
size="xs"
color="light"
variant="border"
title={`Re-start this flow from step ${selectedJobStep} (included).`}
on:click={() => {
runPreview(previewArgs.val, {
flow_job_id: jobId,
step_id: selectedJobStep,
branch_or_iteration_n: 0
})
}}
startIcon={{ icon: Play }}
>
Re-start from
<Badge baseClass="ml-1" color="indigo">
{selectedJobStep}
</Badge>
</Button>
{:else}
<Popover
floatingConfig={{ strategy: 'absolute', placement: 'bottom-start' }}
contentClasses="p-4"
>
<svelte:fragment slot="button">
<Button
title={`Re-start this flow from step ${selectedJobStep} (included).`}
variant="border"
color="blue"
startIcon={{ icon: RefreshCw }}
on:click={() => {
runPreview(previewArgs.val, {
flow_job_id: jobId,
step_id: selectedJobStep,
branch_or_iteration_n: 0
})
}}
nonCaptureEvent={true}
>
Re-start from
<Badge baseClass="ml-1" color="indigo">
{selectedJobStep}
</Badge>
</Button>
</svelte:fragment>
<svelte:fragment slot="content">
<label class="block text-primary p-4">
<div class="pb-1 text-sm text-secondary"
>{selectedJobStepType == 'forloop' ? 'From iteration #:' : 'From branch:'}</div
>
<div class="flex w-full">
{#if selectedJobStepType === 'forloop'}
<input
type="number"
min="0"
bind:value={branchOrIterationN}
class="!w-32 grow"
on:click|stopPropagation={() => {}}
/>
{:else}
<select
bind:value={branchOrIterationN}
class="!w-32 grow"
on:click|stopPropagation={() => {}}
>
{#each restartBranchNames as [branchIdx, branchName]}
<option value={branchIdx}>{branchName}</option>
{/each}
</select>
{/if}
<Button
size="xs"
color="blue"
buttonType="button"
btnClasses="!p-1 !w-[34px] !ml-1"
aria-label="Restart flow"
on:click|once={() => {
runPreview(previewArgs.val, {
flow_job_id: jobId,
step_id: selectedJobStep,
branch_or_iteration_n: branchOrIterationN
})
}}
>
<ArrowRight size={18} />
</Button>
</div>
</label>
</svelte:fragment>
</Popover>
{/if}
{/if}
<Button
variant="contained"
startIcon={{ icon: isRunning ? RefreshCw : Play }}
color="dark"
size="sm"
btnClasses="w-full max-w-lg"
on:click={() => runPreview(previewArgs.val, undefined)}
id="flow-editor-test-flow-drawer"
shortCut={{ Icon: CornerDownLeft }}
>
{#if previewMode == 'upTo'}
Test up to
<Badge baseClass="ml-1" color="indigo">
{$selectedId}
</Badge>
{:else}
Test flow
{/if}
</Button>
</div>
{/if}
</div>
<div class="w-full flex flex-col gap-y-1">
{#if lastPreviewFlow && JSON.stringify(flowStore.val) != lastPreviewFlow}
<div class="pt-1">
<div
class="bg-orange-200 text-orange-600 border border-orange-600 p-2 flex items-center gap-2 rounded"
>
<AlertTriangle size={14} /> Flow changed since last preview
<div class="flex"></div>
<div class="w-full flex flex-col gap-y-1">
{#if flowHasChanged()}
<div class="pt-1">
<div
class="bg-orange-200 text-orange-600 border border-orange-600 p-2 flex items-center gap-2 rounded"
>
<AlertTriangle size={14} /> Flow changed since last preview
<div class="flex"></div>
</div>
</div>
</div>
{/if}
<FlowProgressBar {job} bind:reset={jobProgressReset} />
</div>
{/if}
<FlowProgressBar {job} bind:this={flowProgressBar} />
</div>
{/if}
<div
bind:this={scrollableDiv}
class="overflow-y-auto grow flex flex-col pt-4"
on:scroll={(e) => handleScroll()}
onscroll={(e) => handleScroll()}
>
<div class="border-b">
<SchemaFormWithArgPicker
bind:this={schemaFormWithArgPicker}
runnableId={$initialPathStore}
stablePathForCaptures={$initialPathStore || fakeInitialPath}
runnableType={'FlowPath'}
previewArgs={previewArgs.val}
on:openTriggers
on:select={(e) => {
selectInput(e.detail.payload, e.detail?.type)
}}
{isValid}
{jsonView}
>
<div class="w-full flex flex-row justify-between">
<InputSelectedBadge
onReject={() => schemaFormWithArgPicker?.resetSelected()}
{inputSelected}
/>
<div class="flex flex-row gap-2">
<Toggle
bind:checked={jsonView}
label="JSON View"
size="xs"
options={{
right: 'JSON',
rightTooltip: 'Fill args from JSON'
}}
lightMode
on:change={(e) => {
jsonEditor?.setCode(JSON.stringify(previewArgs.val ?? {}, null, '\t'))
refresh()
}}
{#if render}
<div class="border-b">
<SchemaFormWithArgPicker
bind:this={schemaFormWithArgPicker}
runnableId={$initialPathStore}
stablePathForCaptures={$initialPathStore || fakeInitialPath}
runnableType={'FlowPath'}
previewArgs={previewArgs.val}
on:openTriggers
on:select={(e) => {
selectInput(e.detail.payload, e.detail?.type)
}}
{isValid}
{jsonView}
>
<div class="w-full flex flex-row justify-between">
<InputSelectedBadge
onReject={() => schemaFormWithArgPicker?.resetSelected()}
{inputSelected}
/>
</div>
</div>
{#if jsonView}
<div class="py-2" style="height: {Math.max(schemaHeight, 100)}px" data-schema-picker>
<JsonInputs
bind:this={jsonEditor}
on:select={(e) => {
if (e.detail) {
previewArgs.val = e.detail
}
}}
updateOnBlur={false}
placeholder={`Write args as JSON.<br/><br/>Example:<br/><br/>{<br/>&nbsp;&nbsp;"foo": "12"<br/>}`}
/>
</div>
{:else}
{#key renderCount}
<div bind:clientHeight={schemaHeight} class="min-h-[40vh]">
<SchemaForm
noVariablePicker
compact
schema={flowStore.val.schema}
bind:args={previewArgs.val}
on:change={() => {
savedArgs = previewArgs.val
<div class="flex flex-row gap-2">
<Toggle
bind:checked={jsonView}
label="JSON View"
size="xs"
options={{
right: 'JSON',
rightTooltip: 'Fill args from JSON'
}}
lightMode
on:change={(e) => {
jsonEditor?.setCode(JSON.stringify(previewArgs.val ?? {}, null, '\t'))
refresh()
}}
bind:isValid
/>
</div>
{/key}
{/if}
</SchemaFormWithArgPicker>
</div>
</div>
{#if jsonView}
<div class="py-2" style="height: {Math.max(schemaHeight, 100)}px" data-schema-picker>
<JsonInputs
bind:this={jsonEditor}
on:select={(e) => {
if (e.detail) {
previewArgs.val = e.detail
}
}}
updateOnBlur={false}
placeholder={`Write args as JSON.<br/><br/>Example:<br/><br/>{<br/>&nbsp;&nbsp;"foo": "12"<br/>}`}
/>
</div>
{:else}
{#key renderCount}
<div bind:clientHeight={schemaHeight} class="min-h-[40vh]">
<SchemaForm
noVariablePicker
compact
schema={flowStore.val.schema}
bind:args={previewArgs.val}
on:change={() => {
savedArgs = previewArgs.val
}}
bind:isValid
/>
</div>
{/key}
{/if}
</SchemaFormWithArgPicker>
</div>
{/if}
<div class="pt-4 flex flex-col grow relative">
<div
class="absolute top-[22px] right-2 border p-1.5 hover:bg-surface-hover rounded-md center-center"
>
<FlowHistoryJobPicker
selectInitial={jobId == undefined}
on:select={(e) => {
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}
<FlowHistoryJobPicker
selectInitial={jobId == undefined}
on:select={(e) => {
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}
</div>
<!-- svelte-ignore a11y_click_events_have_key_events -->
{#if jobId}
{#if stepHistoryLoader?.flowJobInitial}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
on:click={() => {
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}
<div class="italic text-tertiary h-full grow"> Flow status will be displayed here </div>
@@ -0,0 +1,103 @@
<script lang="ts">
import { Badge, Loader2 } from 'lucide-svelte'
import FlowJobResult from './FlowJobResult.svelte'
import FlowPreviewStatus from './preview/FlowPreviewStatus.svelte'
import FlowStatusWaitingForEvents from './FlowStatusWaitingForEvents.svelte'
import type { FlowStatusModule, Job } from '$lib/gen'
import { emptyString } from '$lib/utils'
import type { DurationStatus } from './graph'
import type { Writable } from 'svelte/store'
interface Props {
job: Job
workspaceId: string | undefined
isOwner: boolean
hideFlowResult: boolean
hideDownloadLogs: boolean
localDurationStatuses: Writable<Record<string, DurationStatus>>
innerModules: FlowStatusModule[]
suspendStatus: Writable<Record<string, { job: Job; nb: number }>>
hideJobId?: boolean
extra?: import('svelte').Snippet
}
let {
job,
workspaceId,
isOwner,
hideFlowResult,
hideDownloadLogs,
localDurationStatuses,
innerModules,
suspendStatus,
hideJobId,
extra
}: Props = $props()
</script>
<FlowPreviewStatus {job} {hideJobId} {extra} />
{#if !job}
<div>
<Loader2 class="animate-spin" />
</div>
{:else if `result` in job}
{#if !hideFlowResult}
<div class="w-full h-full">
<FlowJobResult
workspaceId={job?.workspace_id}
jobId={job?.id}
tag={job?.tag}
loading={job['running'] == true}
result={job.result}
logs={job.logs}
durationStates={localDurationStatuses}
downloadLogs={!hideDownloadLogs}
/>
</div>
{/if}
{:else if job.flow_status?.modules?.[job?.flow_status?.step]?.type === 'WaitingForEvents'}
<FlowStatusWaitingForEvents {workspaceId} {job} {isOwner} />
{:else if $suspendStatus && Object.keys($suspendStatus).length > 0}
<div class="flex gap-2 flex-col">
{#each Object.values($suspendStatus) as suspendCount (suspendCount.job.id)}
<div>
<div class="text-sm">
Flow suspended, waiting for {suspendCount.nb} events
</div>
<FlowStatusWaitingForEvents job={suspendCount.job} {workspaceId} {isOwner} />
</div>
{/each}
</div>
{:else if job.logs}
<div
class="text-xs p-4 bg-surface-secondary overflow-auto max-h-80 border border-tertiary-inverse"
>
<pre class="w-full">{job.logs}</pre>
</div>
{:else if innerModules?.length > 0}
<div class="flex flex-col gap-1">
{#each innerModules as mod, i (mod.id)}
{#if mod.type == 'InProgress'}
{@const rawMod = job.raw_flow?.modules[i]}
<div
><span class="inline-flex gap-1"
><Badge color="indigo">{mod.id}</Badge>
<span class="font-medium text-primary">
{#if !emptyString(rawMod?.summary)}
{rawMod?.summary ?? ''}
{:else if rawMod?.value.type == 'script'}
{rawMod.value.path ?? ''}
{:else if rawMod?.value.type}
{rawMod?.value.type}
{/if}
</span>
<Loader2 class="animate-spin" /></span
></div
>
{/if}
{/each}
</div>
{/if}
@@ -26,11 +26,12 @@
export let localModuleStates: Writable<Record<string, GraphModuleState>> = writable({})
export let localDurationStatuses: Writable<Record<string, DurationStatus>> = 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<FlowStatusViewerContext>('FlowStatusViewer', {
flowStateStore,
suspendStatus,
@@ -85,4 +86,5 @@
{isOwner}
{wideResults}
bind:rightColumnSelect
{render}
/>
@@ -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<FlowStatusViewerContext>('FlowStatusViewer')
export let jobId: string
@@ -916,71 +916,17 @@
{/if}
{:else if render}
<div class={'border rounded-md shadow p-2'}>
<FlowPreviewStatus {job} />
{#if !job}
<div>
<Loader2 class="animate-spin" />
</div>
{:else if `result` in job}
{#if !hideFlowResult}
<div class="w-full h-full">
<FlowJobResult
workspaceId={job?.workspace_id}
jobId={job?.id}
tag={job?.tag}
loading={job['running'] == true}
result={job.result}
logs={job.logs}
durationStates={localDurationStatuses}
downloadLogs={!hideDownloadLogs}
/>
</div>
{/if}
{:else if job.flow_status?.modules?.[job?.flow_status?.step]?.type === 'WaitingForEvents'}
<FlowStatusWaitingForEvents {workspaceId} {job} {isOwner} />
{:else if $suspendStatus && Object.keys($suspendStatus).length > 0}
<div class="flex gap-2 flex-col">
{#each Object.values($suspendStatus) as suspendCount (suspendCount.job.id)}
<div>
<div class="text-sm">
Flow suspended, waiting for {suspendCount.nb} events
</div>
<FlowStatusWaitingForEvents job={suspendCount.job} {workspaceId} {isOwner} />
</div>
{/each}
</div>
{:else if job.logs}
<div
class="text-xs p-4 bg-surface-secondary overflow-auto max-h-80 border border-tertiary-inverse"
>
<pre class="w-full">{job.logs}</pre>
</div>
{:else if innerModules?.length > 0}
<div class="flex flex-col gap-1">
{#each innerModules as mod, i (mod.id)}
{#if mod.type == 'InProgress'}
{@const rawMod = job.raw_flow?.modules[i]}
<div
><span class="inline-flex gap-1"
><Badge color="indigo">{mod.id}</Badge>
<span class="font-medium text-primary">
{#if !emptyString(rawMod?.summary)}
{rawMod?.summary ?? ''}
{:else if rawMod?.value.type == 'script'}
{rawMod.value.path ?? ''}
{:else if rawMod?.value.type}
{rawMod?.value.type}
{/if}
</span>
<Loader2 class="animate-spin" /></span
></div
>
{/if}
{/each}
</div>
{/if}
<FlowPreviewResult
{job}
{workspaceId}
{isOwner}
{hideFlowResult}
{hideDownloadLogs}
{localDurationStatuses}
{innerModules}
{suspendStatus}
{hideJobId}
/>
</div>
{/if}
{#if render}
@@ -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 @@
</script>
<div class="w-full h-full mt-2 text-sm text-tertiary">
<p>Waiting to be resumed</p>
{#if !light}
<p>Waiting to be resumed</p>
{/if}
{#if description != undefined}
<DisplayResult {workspaceId} noControls result={description} language={job?.language}/>
<DisplayResult {workspaceId} noControls result={description} language={job?.language} />
{/if}
<div>
{#if isOwner || resumeUrl}
+26 -28
View File
@@ -1,17 +1,8 @@
<script lang="ts" module>
type testModuleState = {
loading: boolean
cancel?: () => Promise<void>
}
let testModulesState = $state<Record<string, testModuleState>>({})
</script>
<script lang="ts">
import { ScriptService, type FlowModule, type Job } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { getScriptByPath } from '$lib/scripts'
import { getContext, onMount } from 'svelte'
import { getContext } from 'svelte'
import type { FlowEditorContext } from './flows/types'
import TestJobLoader from './TestJobLoader.svelte'
import { getStepHistoryLoaderContext } from './stepHistoryLoader.svelte'
@@ -32,7 +23,7 @@
scriptProgress = $bindable(undefined)
}: Props = $props()
const { flowStore, flowStateStore, pathStore, testSteps, previewArgs } =
const { flowStore, flowStateStore, pathStore, testSteps, previewArgs, modulesTestStates } =
getContext<FlowEditorContext>('FlowEditorContext')
let testJobLoader: TestJobLoader | undefined = $state(undefined)
@@ -52,9 +43,12 @@
// Not defined if JobProgressBar not loaded
if (jobProgressReset) jobProgressReset()
testModulesState[mod.id].cancel = async () => {
await testJobLoader?.cancelJob()
testJob = undefined
if (modulesTestStates.states[mod.id]) {
modulesTestStates.states[mod.id].cancel = async () => {
await testJobLoader?.cancelJob()
modulesTestStates.states[mod.id].testJob = undefined
}
modulesTestStates.runTestCb?.(mod.id)
}
const val = mod.value
@@ -98,24 +92,28 @@
}
stepHistoryLoader?.resetInitial(mod.id)
}
testJob = undefined
modulesTestStates.states[mod.id].testJob = undefined
}
export function cancelJob() {
testModulesState[mod.id]?.cancel?.()
modulesTestStates.states[mod.id]?.cancel?.()
}
$effect(() => {
testIsLoading = testModulesState[mod.id]?.loading ?? false
// Update testIsLoading to read the state from parent components
testIsLoading = modulesTestStates.states[mod.id]?.loading ?? false
})
onMount(() => {
const modId = mod.id
testModulesState[modId] = {
...(testModulesState[modId] ?? { loading: false, instances: 0 }),
loading: testIsLoading
}
$effect(() => {
// Update testJob to read the state from parent components
testJob = modulesTestStates.states[mod.id]?.testJob
})
modulesTestStates.states[mod.id] = {
...(modulesTestStates.states[mod.id] ?? { loading: false }),
loading: testIsLoading,
testJob: testJob
}
</script>
<TestJobLoader
@@ -124,16 +122,16 @@
bind:scriptProgress
bind:this={testJobLoader}
bind:isLoading={
() => 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}
/>
@@ -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()}
<div
@@ -7,13 +7,14 @@
import { getContext, onDestroy, onMount, setContext } from 'svelte'
import type { FlowEditorContext } from './types'
import { writable } from 'svelte/store'
import { writable, type Writable } from 'svelte/store'
import type { PropPickerContext, FlowPropPickerConfig } from '$lib/components/prop_picker'
import type { PickableProperties } from '$lib/components/flows/previousResults'
import type { Flow } from '$lib/gen'
import type { Flow, Job } from '$lib/gen'
import type { Trigger } from '$lib/components/triggers/utils'
import FlowAIChat from '../copilot/chat/flow/FlowAIChat.svelte'
import { aiChatManager, AIMode } from '../copilot/chat/AIChatManager.svelte'
import type { DurationStatus, GraphModuleState } from '../graph'
import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte'
const { flowStore } = getContext<FlowEditorContext>('FlowEditorContext')
@@ -39,7 +40,20 @@
aiChatOpen?: boolean
showFlowAiButton?: boolean
toggleAiChat?: () => void
onRunPreview?: () => void
localModuleStates?: Writable<Record<string, GraphModuleState>>
isOwner?: boolean
onTestFlow?: () => void
isRunning?: boolean
onCancelTestFlow?: () => void
onOpenPreview?: () => void
onHideJobStatus?: () => void
individualStepTests?: boolean
job?: Job
localDurationStatuses?: Writable<Record<string, DurationStatus>>
suspendStatus?: Writable<Record<string, { job: Job; nb: number }>>
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>('PropPickerContext', {
flowPropPickerConfig: writable<FlowPropPickerConfig | undefined>(undefined),
pickablePropertiesFiltered: writable<PickableProperties | undefined>(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}
</div>
@@ -139,7 +183,12 @@
{onDeployTrigger}
{forceTestTab}
{highlightArg}
{onRunPreview}
{onTestFlow}
{job}
{isOwner}
{localDurationStatuses}
{suspendStatus}
onOpenDetails={onOpenPreview}
/>
{/if}
</Pane>
@@ -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<string, boolean>
highlightArg?: Record<string, string | undefined>
onRunPreview?: () => void
onTestFlow?: () => void
job?: Job
isOwner?: boolean
localDurationStatuses?: Writable<Record<string, DurationStatus>>
suspendStatus?: Writable<Record<string, { job: Job; nb: number }>>
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'}
<p class="p-4 text-secondary">The result of the flow will be the result of the last node.</p>
<FlowResult {noEditor} {job} {isOwner} {localDurationStatuses} {suspendStatus} {onOpenDetails} />
{:else if $selectedId === 'constants'}
<FlowConstants {noEditor} />
{:else if $selectedId === 'failure'}
@@ -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) {
@@ -0,0 +1,50 @@
<script lang="ts">
import FlowPreviewResult from '$lib/components/FlowPreviewResult.svelte'
import type { DurationStatus } from '$lib/components/graph'
import type { Job } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import type { Writable } from 'svelte/store'
import FlowCard from '../common/FlowCard.svelte'
import Button from '$lib/components/common/button/Button.svelte'
interface Props {
job?: Job
isOwner?: boolean
localDurationStatuses?: Writable<Record<string, DurationStatus>>
suspendStatus?: Writable<Record<string, { job: Job; nb: number }>>
noEditor: boolean
onOpenDetails?: () => void
}
let { job, isOwner, localDurationStatuses, suspendStatus, noEditor, onOpenDetails }: Props =
$props()
</script>
<FlowCard {noEditor} title="Flow result">
{#if job && isOwner !== undefined && localDurationStatuses && suspendStatus}
<div class="px-4 py-2">
<FlowPreviewResult
{job}
workspaceId={$workspaceStore}
{isOwner}
hideFlowResult={false}
hideDownloadLogs={false}
{localDurationStatuses}
innerModules={[]}
{suspendStatus}
{extra}
hideJobId
/>
</div>
{:else}
<p class="p-4 text-secondary">The result of the flow will be the result of the last node.</p>
{/if}
</FlowCard>
{#snippet extra()}
<div class="flex justify-end">
<Button variant="border" color="light" size="xs" on:click={() => onOpenDetails?.()}
>Open details</Button
>
</div>
{/snippet}
@@ -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>('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<Record<string, GraphModuleState>> = $state(writable({}))
let localDurationStatuses: Writable<Record<string, DurationStatus>> = $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
}
</script>
@@ -101,7 +137,7 @@
? [
{
label: 'Test up to ' + $selectedId,
onClick: testUpTo
onClick: () => testUpTo($selectedId, true)
}
]
: undefined}
@@ -110,22 +146,20 @@
</Button>
{#if !loading}
<Drawer bind:open={previewOpen} size="75%" {preventEscape}>
<Drawer bind:open={previewOpen} size="75%" {preventEscape} alwaysOpen={deferContent}>
<FlowPreviewContent
bind:this={flowPreviewContent}
bind:localModuleStates
bind:localDurationStatuses
open={previewOpen}
bind:scrollTop
bind:previewMode
bind:job
bind:jobId
bind:selectedJobStep
bind:selectedJobStepIsTopLevel
bind:selectedJobStepType
bind:branchOrIterationN
bind:rightColumnSelect
on:close={() => {
// 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}
/>
</Drawer>
{/if}
@@ -0,0 +1,124 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import { fade } from 'svelte/transition'
import { Loader2, Play, X } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
import Popover from '$lib/components/Popover.svelte'
import type { Job } from '$lib/gen'
interface Props {
isRunning?: boolean
hover?: boolean
selected?: boolean
individualStepTests?: boolean
showJobStatus?: boolean
job?: Job
flowHasChanged?: boolean
onTestFlow?: () => void
onCancelTestFlow?: () => void
onOpenPreview?: () => void
onHideJobStatus?: () => void
}
let {
isRunning,
hover,
selected,
individualStepTests,
job,
showJobStatus,
flowHasChanged,
onTestFlow,
onCancelTestFlow,
onOpenPreview,
onHideJobStatus
}: Props = $props()
const flowPreviewJob = $derived(showJobStatus ? job : undefined)
const wide = $derived(hover || selected || flowPreviewJob || individualStepTests)
</script>
{#if !isRunning}
<Button
size="sm"
color="dark"
btnClasses={twMerge(
'relative p-1.5 h-[36px] transition-all duration-200',
wide ? 'w-[120px]' : 'w-[44.5px]'
)}
on:click={() => {
onTestFlow?.()
}}
>
{#if isRunning}
<Loader2 size={16} class="animate-spin" />
{:else}
<Play size={16} />
{/if}
{#if wide}
<span transition:fade={{ duration: 100 }} class="text-xs">Test flow</span>
{/if}
</Button>
{#if wide && (flowPreviewJob || individualStepTests)}
<div
class="flex flex-row items-center shadow-sm rounded-md mt-1"
in:fade={{ duration: 100, delay: 200 }}
style={`width: ${wide ? '120px' : '44.5px'}`}
>
<Popover class="grow min-w-0">
<button
class={twMerge(
'text-xs rounded-md rounded-r-none px-1.5 h-[24px] w-full bg-surface flex flex-row items-center gap-2 justify-center transition-all duration-200 ',
'hover:bg-surface-hover text-gray-400 hover:text-primary'
)}
onclick={onOpenPreview}
>
{#if flowPreviewJob && !flowHasChanged}
<div
class={twMerge(
'rounded-full h-2 w-2',
'success' in flowPreviewJob && flowPreviewJob.success
? 'bg-green-400'
: 'bg-red-400'
)}
title={'success' in flowPreviewJob && flowPreviewJob.success ? 'Success' : 'Failed'}
>
</div>
{/if}
<span class={twMerge('text-xs truncate', flowHasChanged && 'text-orange-600')} dir="rtl">
{!individualStepTests && !flowHasChanged && flowPreviewJob
? flowPreviewJob.id.slice(-5)
: '~'}
</span>
</button>
{#snippet text()}
{#if !individualStepTests && !flowHasChanged}
See run details
{:else}
Open preview
{/if}
{/snippet}
</Popover>
<button
class="h-[24px] px-1.5 bg-surface rounded-md rounded-l-none text-gray-400 hover:bg-red-500 hover:text-white"
title="Hide jobs status"
onclick={onHideJobStatus}
>
<X size={14} />
</button>
</div>
{/if}
{:else}
<Button
size="xs"
color="red"
variant="contained"
btnClasses="h-[34px] w-[120px] p-1.5"
on:click={async () => {
onCancelTestFlow?.()
}}
>
<Loader2 size={16} class="animate-spin" />
<span transition:fade={{ duration: 100 }} class="text-xs">Cancel</span>
</Button>
{/if}
@@ -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<string, any> | 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<string, any> | undefined = $state(undefined)
@@ -193,6 +203,8 @@
const icon_render = $derived(icon)
const action = $derived(getAiModuleAction(id))
let testRunDropdownOpen = $state(false)
</script>
{#if deletable && id && editId}
@@ -261,310 +273,320 @@
{/if}
{/if}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class={classNames(
'w-full module flex rounded-sm cursor-pointer max-w-full ',
'flex relative',
deletable ? aiModuleActionToBgColor(action) : ''
)}
style="width: 275px; height: 34px; background-color: {hover && bgHoverColor
? bgHoverColor
: bgColor};"
onmouseenter={() => (hover = true)}
onmouseleave={() => (hover = false)}
onpointerdown={stopPropagation(preventDefault(() => dispatch('pointerdown')))}
>
{#if deletable}
<ModuleAcceptReject {action} {id} />
{/if}
<div class="relative">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class={classNames(
'absolute rounded-sm outline-offset-0 outline-slate-500 dark:outline-gray-400',
selected ? 'outline outline-2' : 'active:outline active:outline-2'
'w-full module flex rounded-sm cursor-pointer max-w-full',
deletable ? aiModuleActionToBgColor(action) : ''
)}
style={`width: 275px; height: ${outputPickerVisible ? '51px' : '34px'};`}
></div>
<div
class="absolute text-sm right-2 flex flex-row gap-1 z-10 transition-all duration-100"
style={`bottom: ${outputPickerBarOpen ? '-38px' : '-12px'}`}
style="width: 275px; height: 34px; background-color: {hover && bgHoverColor
? bgHoverColor
: bgColor};"
onmouseenter={() => (hover = true)}
onmouseleave={() => (hover = false)}
onpointerdown={stopPropagation(preventDefault(() => dispatch('pointerdown')))}
>
{#if retry}
<Popover notClickable>
<div
transition:fade|local={{ duration: 200 }}
class="center-center rounded border bg-surface border-gray-400 text-secondary px-1 py-0.5"
>
{#if retries}<span class="text-red-400 mr-2">{retries}</span>{/if}
<Repeat size={12} />
</div>
{#snippet text()}
Retries
{/snippet}
</Popover>
{#if deletable}
<ModuleAcceptReject {action} {id} />
{/if}
{#if concurrency}
<Popover notClickable>
<div
transition:fade|local={{ duration: 200 }}
class="center-center rounded border bg-surface border-gray-400 text-secondary px-1 py-0.5"
>
<Gauge size={12} />
</div>
{#snippet text()}
Concurrency Limits
{/snippet}
</Popover>
{/if}
{#if cache}
<Popover notClickable>
<div
transition:fade|local={{ duration: 200 }}
class="center-center rounded border bg-surface border-gray-400 text-secondary px-1 py-0.5"
>
<Database size={12} />
</div>
{#snippet text()}
Cached
{/snippet}
</Popover>
{/if}
{#if earlyStop}
<Popover notClickable>
<div
transition:fade|local={{ duration: 200 }}
class="center-center bg-surface rounded border border-gray-400 text-secondary px-1 py-0.5"
>
<Square size={12} />
</div>
{#snippet text()}
{isTrigger ? 'Stop early if there are no new events' : 'Early stop/break'}
{/snippet}
</Popover>
{/if}
{#if skip}
<Popover notClickable>
<div
transition:fade|local={{ duration: 200 }}
class="center-center bg-surface rounded border border-gray-400 text-secondary px-1 py-0.5"
>
<SkipForward size={12} />
</div>
{#snippet text()}
Skip
{/snippet}
</Popover>
{/if}
{#if suspend}
<Popover notClickable>
<div
transition:fade|local={{ duration: 200 }}
class="center-center bg-surface rounded border border-gray-400 text-secondary px-1 py-0.5"
>
<PhoneIncoming size={12} />
</div>
{#snippet text()}
Suspend
{/snippet}
</Popover>
{/if}
{#if sleep}
<Popover notClickable>
<div
transition:fade|local={{ duration: 200 }}
class="center-center bg-surface rounded border border-gray-400 text-secondary px-1 py-0.5"
>
<Bed size={12} />
</div>
{#snippet text()}
Sleep
{/snippet}
</Popover>
{/if}
{#if mock?.enabled}
<Popover notClickable>
<button
transition:fade|local={{ duration: 200 }}
class="center-center bg-surface rounded border border-gray-400 text-secondary px-1 py-0.5"
onclick={() => {
outputPicker?.toggleOpen()
}}
data-popover
>
<Pin size={12} />
</button>
{#snippet text()}
Pinned
{/snippet}
</Popover>
{/if}
</div>
<div
class={twMerge('flex flex-col w-full', deletable && action === 'removed' ? 'opacity-50' : '')}
>
<FlowModuleSchemaItemViewer
{label}
{path}
{id}
deletable={deletable && !action}
{bold}
bind:editId
{hover}
<div
class={classNames(
'absolute rounded-sm outline-offset-0 outline-slate-500 dark:outline-gray-400',
selected ? 'outline outline-2' : 'active:outline active:outline-2'
)}
style={`width: 275px; height: ${outputPickerVisible ? '51px' : '34px'};`}
></div>
<div
class="absolute text-sm right-2 flex flex-row gap-1 z-10 transition-all duration-100"
style={`bottom: ${outputPickerBarOpen ? '-38px' : '-12px'}`}
>
{#snippet icon()}
{@render icon_render?.()}
{/snippet}
</FlowModuleSchemaItemViewer>
{#if outputPickerVisible}
<OutputPicker
bind:this={outputPicker}
{selected}
{hover}
{isConnectingCandidate}
{historyOpen}
{inputTransform}
id={id ?? ''}
bind:bottomBarOpen={outputPickerBarOpen}
{loopStatus}
{onEditInput}
>
{#snippet children({ allowCopy, isConnecting, selectConnection })}
<OutputPickerInner
{allowCopy}
prefix={'results'}
connectingData={isConnecting ? connectingData : undefined}
{mock}
{lastJob}
{testJob}
moduleId={id}
onSelect={selectConnection}
{onUpdateMock}
{path}
{loopStatus}
rightMargin
bind:derivedHistoryOpen={historyOpen}
historyOffset={{ mainAxis: 12, crossAxis: -9 }}
clazz="p-1"
isLoading={testIsLoading ||
(id ? stepHistoryLoader?.stepStates[id]?.loadingJobs : false)}
initial={id ? stepHistoryLoader?.stepStates[id]?.initial : undefined}
/>
{/snippet}
</OutputPicker>
{/if}
</div>
{#if deletable && !action}
{#if enableTestRun}
<div
class="absolute top-1/2 -translate-y-1/2 -translate-x-[100%] -left-[0] flex items-center w-fit px-2 h-9 min-w-14"
>
{#if (hover || selected) && outputPickerVisible}
<div transition:fade={{ duration: 100 }}>
{#if !testIsLoading}
<Button
size="sm"
color="dark"
title="Run"
btnClasses="p-1.5"
on:click={() => {
outputPicker?.toggleOpen(true)
moduleTest?.loadArgsAndRunTest()
}}
dropdownItems={[
{
label: 'Test up to here',
onClick: () => {
if (id) {
onTestUpTo?.(id)
}
}
}
]}
dropdownBtnClasses="!w-4 px-1"
>
{#if testIsLoading}
<Loader2 size={12} class="animate-spin" />
{:else}
<Play size={12} />
{/if}
</Button>
{:else}
<Button
size="xs"
color="red"
variant="contained"
btnClasses="!h-[25.5px] !w-[44.5px] !p-1.5 gap-0.5"
on:click={async () => {
moduleTest?.cancelJob()
}}
>
<Loader2 size={10} class="animate-spin mr-0.5" />
<X size={14} />
</Button>
{/if}
{#if retry}
<Popover notClickable>
<div
transition:fade|local={{ duration: 200 }}
class="center-center rounded border bg-surface border-gray-400 text-secondary px-1 py-0.5"
>
{#if retries}<span class="text-red-400 mr-2">{retries}</span>{/if}
<Repeat size={12} />
</div>
{/if}
</div>
{/if}
<button
class="absolute -top-[10px] -right-[10px] rounded-full h-[20px] w-[20px] trash center-center text-secondary
{#snippet text()}
Retries
{/snippet}
</Popover>
{/if}
{#if concurrency}
<Popover notClickable>
<div
transition:fade|local={{ duration: 200 }}
class="center-center rounded border bg-surface border-gray-400 text-secondary px-1 py-0.5"
>
<Gauge size={12} />
</div>
{#snippet text()}
Concurrency Limits
{/snippet}
</Popover>
{/if}
{#if cache}
<Popover notClickable>
<div
transition:fade|local={{ duration: 200 }}
class="center-center rounded border bg-surface border-gray-400 text-secondary px-1 py-0.5"
>
<Database size={12} />
</div>
{#snippet text()}
Cached
{/snippet}
</Popover>
{/if}
{#if earlyStop}
<Popover notClickable>
<div
transition:fade|local={{ duration: 200 }}
class="center-center bg-surface rounded border border-gray-400 text-secondary px-1 py-0.5"
>
<Square size={12} />
</div>
{#snippet text()}
{isTrigger ? 'Stop early if there are no new events' : 'Early stop/break'}
{/snippet}
</Popover>
{/if}
{#if skip}
<Popover notClickable>
<div
transition:fade|local={{ duration: 200 }}
class="center-center bg-surface rounded border border-gray-400 text-secondary px-1 py-0.5"
>
<SkipForward size={12} />
</div>
{#snippet text()}
Skip
{/snippet}
</Popover>
{/if}
{#if suspend}
<Popover notClickable>
<div
transition:fade|local={{ duration: 200 }}
class="center-center bg-surface rounded border border-gray-400 text-secondary px-1 py-0.5"
>
<PhoneIncoming size={12} />
</div>
{#snippet text()}
Suspend
{/snippet}
</Popover>
{/if}
{#if sleep}
<Popover notClickable>
<div
transition:fade|local={{ duration: 200 }}
class="center-center bg-surface rounded border border-gray-400 text-secondary px-1 py-0.5"
>
<Bed size={12} />
</div>
{#snippet text()}
Sleep
{/snippet}
</Popover>
{/if}
{#if mock?.enabled}
<Popover notClickable>
<button
transition:fade|local={{ duration: 200 }}
class="center-center bg-surface rounded border border-gray-400 text-secondary px-1 py-0.5"
onclick={() => {
outputPicker?.toggleOpen()
}}
data-popover
>
<Pin size={12} />
</button>
{#snippet text()}
Pinned
{/snippet}
</Popover>
{/if}
</div>
<div
class={twMerge('flex flex-col w-full', deletable && action === 'removed' ? 'opacity-50' : '')}
>
<FlowModuleSchemaItemViewer
{label}
{path}
{id}
deletable={deletable && !action}
{bold}
bind:editId
{hover}
>
{#snippet icon()}
{@render icon_render?.()}
{/snippet}
</FlowModuleSchemaItemViewer>
{#if outputPickerVisible}
<OutputPicker
bind:this={outputPicker}
{selected}
{hover}
{isConnectingCandidate}
{historyOpen}
{inputTransform}
id={id ?? ''}
bind:bottomBarOpen={outputPickerBarOpen}
{loopStatus}
{onEditInput}
{type}
{darkMode}
{skipped}
>
{#snippet children({ allowCopy, isConnecting, selectConnection })}
<OutputPickerInner
{allowCopy}
prefix={'results'}
connectingData={isConnecting ? connectingData : undefined}
{mock}
{lastJob}
{testJob}
moduleId={id}
onSelect={selectConnection}
{onUpdateMock}
{path}
{loopStatus}
rightMargin
bind:derivedHistoryOpen={historyOpen}
historyOffset={{ mainAxis: 12, crossAxis: -9 }}
clazz="p-1"
isLoading={testIsLoading ||
(id ? stepHistoryLoader?.stepStates[id]?.loadingJobs : false)}
initial={id ? stepHistoryLoader?.stepStates[id]?.initial : undefined}
/>
{/snippet}
</OutputPicker>
{/if}
</div>
{#if deletable && !action}
<button
class="absolute -top-[10px] -right-[10px] rounded-full h-[20px] w-[20px] trash center-center text-secondary
outline-[1px] outline dark:outline-gray-500 outline-gray-300 bg-surface duration-0 hover:bg-red-400 hover:text-white
{hover || selected ? '' : '!hidden'}"
title="Delete"
onclick={stopPropagation(
preventDefault((event) => dispatch('delete', { id, type: modType }))
)}
>
<X class="mx-[3px]" size={12} strokeWidth={2} />
</button>
title="Delete"
onclick={stopPropagation(
preventDefault((event) => dispatch('delete', { id, type: modType }))
)}
>
<X class="mx-[3px]" size={12} strokeWidth={2} />
</button>
{#if id !== 'preprocessor'}
<button
class="absolute -top-[10px] right-[60px] rounded-full h-[20px] w-[20px] trash center-center text-secondary
{#if id !== 'preprocessor'}
<button
class="absolute -top-[10px] right-[60px] rounded-full h-[20px] w-[20px] trash center-center text-secondary
outline-[1px] outline dark:outline-gray-500 outline-gray-300 bg-surface duration-0 hover:bg-blue-400 hover:text-white
{hover ? '' : '!hidden'}"
onclick={stopPropagation(preventDefault((event) => dispatch('move')))}
title="Move"
>
<Move class="mx-[3px]" size={12} strokeWidth={2} />
</button>
{/if}
onclick={stopPropagation(preventDefault((event) => dispatch('move')))}
title="Move"
>
<Move class="mx-[3px]" size={12} strokeWidth={2} />
</button>
{/if}
{#if (id && Object.values($flowInputsStore?.[id]?.flowStepWarnings || {}).length > 0) || Boolean(warningMessage)}
<div class="absolute -top-[10px] -left-[10px]">
<Popover>
{#snippet text()}
<ul class="list-disc px-2">
{#if id}
{#each Object.values($flowInputsStore?.[id]?.flowStepWarnings || {}) as m}
<li>
{m.message}
</li>
{/each}
{/if}
</ul>
{/snippet}
<div
class={twMerge(
'flex items-center justify-center h-full w-full rounded-md p-0.5 border duration-0 ',
id &&
Object.values($flowInputsStore?.[id]?.flowStepWarnings || {})?.some(
(x) => x.type === 'error'
)
? 'border-red-600 text-red-600 bg-red-100 hover:bg-red-300'
: 'border-yellow-600 text-yellow-600 bg-yellow-100 hover:bg-yellow-300'
)}
>
<AlertTriangle size={14} strokeWidth={2} />
</div>
</Popover>
</div>
{#if (id && Object.values($flowInputsStore?.[id]?.flowStepWarnings || {}).length > 0) || Boolean(warningMessage)}
<div class="absolute -top-[10px] -left-[10px]">
<Popover>
{#snippet text()}
<ul class="list-disc px-2">
{#if id}
{#each Object.values($flowInputsStore?.[id]?.flowStepWarnings || {}) as m}
<li>
{m.message}
</li>
{/each}
{/if}
</ul>
{/snippet}
<div
class={twMerge(
'flex items-center justify-center h-full w-full rounded-md p-0.5 border duration-0 ',
id &&
Object.values($flowInputsStore?.[id]?.flowStepWarnings || {})?.some(
(x) => x.type === 'error'
)
? 'border-red-600 text-red-600 bg-red-100 hover:bg-red-300'
: 'border-yellow-600 text-yellow-600 bg-yellow-100 hover:bg-yellow-300'
)}
>
<AlertTriangle size={14} strokeWidth={2} />
</div>
</Popover>
</div>
{/if}
{/if}
</div>
{#if editMode && enableTestRun && flowJob?.type !== 'QueuedJob'}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute top-1/2 -translate-y-1/2 -translate-x-[100%] -left-[0] flex items-center w-fit px-2 h-9 min-w-14"
onmouseenter={() => (hover = true)}
onmouseleave={() => (hover = false)}
>
{#if (hover || selected || testRunDropdownOpen) && outputPickerVisible}
<div transition:fade={{ duration: 100 }}>
{#if !testIsLoading}
<Button
size="sm"
color="light"
title="Run"
variant="border"
btnClasses="p-1.5"
on:click={() => {
outputPicker?.toggleOpen(true)
moduleTest?.loadArgsAndRunTest()
}}
dropdownItems={[
{
label: 'Test up to here',
onClick: () => {
if (id) {
onTestUpTo?.(id)
}
}
}
]}
dropdownBtnClasses="!w-4 px-1"
bind:dropdownOpen={testRunDropdownOpen}
>
{#if testIsLoading}
<Loader2 size={12} class="animate-spin" />
{:else}
<Play size={12} />
{/if}
</Button>
{:else}
<Button
size="xs"
color="red"
variant="contained"
btnClasses="!h-[25.5px] !w-[44.5px] !p-1.5 gap-0.5"
on:click={async () => {
moduleTest?.cancelJob()
}}
>
<Loader2 size={10} class="animate-spin mr-0.5" />
<X size={14} />
</Button>
{/if}
</div>
{/if}
</div>
{/if}
</div>
@@ -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<Record<string, GraphModuleState>>
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<Record<string, { job: Job; nb: number }>>
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>('TriggerContext')
const { flowPropPickerConfig } = getContext<PropPickerContext>('PropPickerContext')
export async function insertNewModuleAtIndex(
modules: FlowModule[],
index: number,
@@ -223,6 +252,11 @@
let deleteCallback: (() => void) | undefined = $state(undefined)
let dependents: Record<string, string[]> = $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 @@
<div class="z-10 flex-auto grow bg-surface-secondary" bind:clientHeight={minHeight}>
<FlowGraphV2
bind:this={graph}
earlyStop={flowStore.val.value?.skip_expr !== undefined}
cache={flowStore.val.value?.cache_ttl !== undefined}
triggerNode={customUi?.triggers != false}
@@ -353,6 +388,13 @@
editMode
{onTestUpTo}
{onEditInput}
flowModuleStates={$localModuleStates}
{isOwner}
{individualStepTests}
{flowJob}
{showJobStatus}
{suspendStatus}
{flowHasChanged}
onDelete={(id) => {
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}
/>
</div>
</div>
@@ -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}
<div class="absolute z-10 right-0 -top-4 center-center text-tertiary text-2xs">
<div
class={twMerge(
'absolute z-10 right-0 -top-4 center-center text-tertiary text-2xs',
editMode ? 'text-gray-400 dark:text-gray-500 text-2xs font-normal mr-2' : ''
)}
>
{msToSec(duration_ms)}s
</div>
{/if}
{#if annotation && annotation != ''}
<div class="absolute z-10 left-0 -top-5 center-center text-tertiary">
<div
class={twMerge(
'absolute z-10 left-0 -top-5 center-center text-tertiary',
editMode ? '-top-4 text-gray-400 dark:text-gray-500 text-xs font-normal' : ''
)}
>
{annotation}
</div>
{/if}
@@ -160,6 +181,8 @@
alwaysShowOutputPicker={!mod.id.startsWith('subflow:')}
loopStatus={{ type: 'self', flow: mod.value.type }}
{onTestUpTo}
{type}
{darkMode}
>
{#snippet icon()}
<div>
@@ -181,6 +204,8 @@
{bgColor}
{bgHoverColor}
{onTestUpTo}
{type}
{darkMode}
>
{#snippet icon()}
<div>
@@ -202,6 +227,8 @@
{bgColor}
{bgHoverColor}
{onTestUpTo}
{type}
{darkMode}
>
{#snippet icon()}
<div>
@@ -246,7 +273,12 @@
inputTransform={mod.value.type !== 'identity' ? mod.value.input_transforms : undefined}
{onTestUpTo}
{onEditInput}
{flowJob}
{isOwner}
enableTestRun
{type}
{darkMode}
{skipped}
>
{#snippet icon()}
<div>
@@ -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
)
</script>
<VirtualItemWrapper
@@ -112,6 +148,8 @@
id={id ?? ''}
isConnectingCandidate={true}
variant="virtual"
type={outputType}
{darkMode}
>
{#snippet children({ allowCopy, isConnecting, selectConnection })}
<OutputPickerInner
@@ -121,18 +159,25 @@
onSelect={selectConnection}
moduleId={''}
{onUpdateMock}
hideHeaderBar
hideHeaderBar={nodeKind !== 'result'}
simpleViewer={inputJson}
rightMargin
historyOffset={{ mainAxis: 12, crossAxis: -9 }}
clazz="p-1"
{onEditInput}
selectionId={id ?? label ?? ''}
testJob={job}
disableMock
disableHistory
customEmptyJobMessage={nodeKind === 'result'
? 'Test the flow to see results'
: undefined}
/>
{/snippet}
</OutputPicker>
{/if}
</div>
<div class="absolute text-sm right-12 -bottom-3 flex flex-row gap-1 z-10">
{#if cache}
<Popover notClickable>
@@ -162,4 +207,36 @@
{/if}
</div>
{/snippet}
{#snippet previewButton()}
{#if nodeKind === 'input'}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute top-1/2 -translate-y-[35px] -translate-x-[100%] -left-[0] flex py-4 justify-end w-fit px-2 min-w-32"
onmouseenter={() => {
hoverButton = true
}}
onmouseleave={() => {
hoverButton = false
}}
>
{#if outputPickerVisible}
<div transition:fade={{ duration: 100 }}>
<FlowGraphPreviewButton
{isRunning}
hover={hoverButton}
{selected}
{onTestFlow}
{onCancelTestFlow}
{onOpenPreview}
{onHideJobStatus}
{individualStepTests}
{job}
{showJobStatus}
{flowHasChanged}
/>
</div>
{/if}
</div>
{/if}
{/snippet}
</VirtualItemWrapper>
@@ -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)
</script>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class={classNames('w-full flex relative rounded-sm', onTop ? 'z-[901]' : '', className)}
style="width: 275px; max-height: 34px; background-color: {hover && bgHoverColor && selectable
? bgHoverColor
: bgColor};"
onpointerdown={() => {
if (selectable) {
dispatch('select', id || label || '')
}
}}
onmouseenter={() => {
hover = true
}}
onmouseleave={() => {
hover = false
}}
title={label ? label + ' ' : ''}
id={`flow-editor-virtual-${encodeURIComponent(label || label || '')}`}
>
<div class="relative">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class={twMerge(
'absolute outline-gray-600 dark:outline-gray-400 rounded-sm',
selected ? 'outline outline-2' : '',
selectable ? 'cursor-pointer active:outline active:outline-2' : ''
)}
style={`width: 275px; height: ${outputPickerVisible ? '50px' : '34px'};`}
class={classNames('w-full flex relative rounded-sm', onTop ? 'z-[901]' : '', className)}
style="width: 275px; max-height: 34px; background-color: {hover && bgHoverColor && selectable
? bgHoverColor
: bgColor};"
onpointerdown={() => {
if (selectable) {
dispatch('select', id || label || '')
}
}}
onmouseenter={() => {
hover = true
}}
onmouseleave={() => {
hover = false
}}
title={label ? label + ' ' : ''}
id={`flow-editor-virtual-${encodeURIComponent(label || label || '')}`}
>
<div
class={twMerge(
'absolute outline-gray-600 dark:outline-gray-400 rounded-sm',
selected ? 'outline outline-2' : '',
selectable ? 'cursor-pointer active:outline active:outline-2' : ''
)}
style={`width: 275px; height: ${outputPickerVisible ? '50px' : '34px'};`}
>
</div>
{@render children?.({ hover })}
</div>
{@render children?.({ hover })}
{@render previewButton?.()}
</div>
@@ -1,12 +1,15 @@
<script lang="ts">
import Popover from '$lib/components/meltComponents/Popover.svelte'
import { twMerge } from 'tailwind-merge'
import { getContext } from 'svelte'
import { getContext, onMount } from 'svelte'
import type { PropPickerContext } from '$lib/components/prop_picker'
import AnimatedButton from '$lib/components/common/button/AnimatedButton.svelte'
import InputPickerInner from './InputPickerInner.svelte'
import { ChevronDown, Plug } from 'lucide-svelte'
import { useSvelteFlow } from '@xyflow/svelte'
import { getStateColor } from '$lib/components/graph/util'
import type { FlowStatusModule } from '$lib/gen'
import type { FlowEditorContext } from '../types'
interface Props {
selected?: boolean
@@ -22,6 +25,9 @@
onEditInput?: (moduleId: string, key: string) => void
initial?: boolean
onResetInitial?: () => void
type: FlowStatusModule['type'] | undefined
darkMode?: boolean
skipped?: boolean
}
let {
@@ -35,7 +41,10 @@
id,
bottomBarOpen = $bindable(false),
loopStatus,
onEditInput
onEditInput,
type,
darkMode,
skipped
}: Props = $props()
const context = getContext<PropPickerContext>('PropPickerContext')
@@ -104,6 +113,18 @@
$effect(() => {
updatePositioning(historyOpen, zoom)
})
onMount(() => {
let { outputPickerOpenFns } = getContext<FlowEditorContext>('FlowEditorContext') || {}
if (outputPickerOpenFns) {
outputPickerOpenFns[id] = () => {
outputOpen = true
}
return () => {
delete outputPickerOpenFns[id]
}
}
})
</script>
<div
@@ -127,6 +148,9 @@
'h-1 hover:h-[20px]',
bottomBarOpen && 'h-[20px]'
)}
style:background-color={type && type !== 'WaitingForEvents'
? getStateColor(type, !!darkMode, true, skipped)
: undefined}
data-prop-picker
>
<div class="flex flex-row items-center justify-center w-full h-full">
@@ -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}
<div class="flex flex-col items-center justify-center h-full">
<p class="text-xs text-secondary">
Test this step to see results{#if !disableMock}
{customEmptyJobMessage ?? 'Test this step to see results'}{#if !disableMock}
{' or'}
<button
class="text-blue-500 hover:text-blue-700 underline"
@@ -8,6 +8,7 @@ import type Editor from '../Editor.svelte'
import type SimpleEditor from '../SimpleEditor.svelte'
import type { StateStore } from '$lib/utils'
import type { TestSteps } from './testSteps.svelte'
import type { ModulesTestStates } from '../modulesTest.svelte'
export type FlowInput = Record<
string,
@@ -78,4 +79,6 @@ export type FlowEditorContext = {
customUi: FlowBuilderWhitelabelCustomUi
insertButtonOpen: Writable<boolean>
executionCount: Writable<number>
modulesTestStates: ModulesTestStates
outputPickerOpenFns: Record<string, () => void>
}
+50 -1
View File
@@ -9,12 +9,14 @@ import {
} from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { cleanExpr, emptySchema } from '$lib/utils'
import { get } from 'svelte/store'
import { get, type Writable } from 'svelte/store'
import type { FlowModuleState } from './flowState'
import { type PickableProperties, dfs } from './previousResults'
import { NEVER_TESTED_THIS_FAR } from './models'
import { sendUserToast } from '$lib/toast'
import type { ExtendedOpenFlow } from './types'
import type { GraphModuleState } from '../graph'
import type { ModulesTestStates } from '../modulesTest.svelte'
function create_context_function_template(eval_string: string, context: Record<string, any>) {
return `
@@ -195,3 +197,50 @@ export function checkIfParentLoop(
}
return undefined
}
/**
* Updates moduleStates based on test job data from modulesTestStates
* Extracts job information and converts it to GraphModuleState format
* for graph rendering
*/
export function updateDerivedModuleStatesFromTestJobs(
moduleId: string | undefined,
moduleTestStates: ModulesTestStates | undefined,
moduleStates: Writable<Record<string, GraphModuleState>> | undefined
) {
if (!moduleId || !moduleTestStates || !moduleStates) {
return
}
const newStates: Record<string, GraphModuleState> = {}
const testState = moduleTestStates?.states[moduleId]
if (testState) {
if (testState.testJob) {
const job = testState.testJob
// Create GraphModuleState from job data
const moduleState: GraphModuleState = {
args: job.args,
type: job.type === 'QueuedJob' ? 'InProgress' : job['success'] ? 'Success' : 'Failure',
job_id: job.id,
tag: job.tag,
duration_ms: job['duration_ms'],
started_at: job.started_at ? new Date(job.started_at).getTime() : undefined
}
newStates[moduleId] = moduleState
} else if (testState.loading) {
// If test is loading, show as InProgress
newStates[moduleId] = {
type: 'InProgress',
args: {}
}
}
}
// Update the store with test job states
moduleStates.update((currentStates) => ({
...currentStates,
...newStates
}))
}
@@ -1,5 +1,5 @@
<script lang="ts">
import { FlowService, type FlowModule } from '../../gen'
import { FlowService, type FlowModule, type Job } from '../../gen'
import { NODE, type GraphModuleState } from '.'
import { getContext, onDestroy, setContext, tick, untrack } from 'svelte'
@@ -86,6 +86,12 @@
editMode?: boolean
allowSimplifiedPoll?: boolean
expandedSubflows?: Record<string, FlowModule[]>
isOwner?: boolean
isRunning?: boolean
individualStepTests?: boolean
flowJob?: Job | undefined
showJobStatus?: boolean
suspendStatus?: Writable<Record<string, { job: Job; nb: number }>>
onDelete?: (id: string) => void
onInsert?: (detail: {
sourceId?: string
@@ -108,6 +114,11 @@
onTestUpTo?: ((id: string) => void) | undefined
onSelectedIteration?: onSelectedIteration
onEditInput?: (moduleId: string, key: string) => void
onTestFlow?: () => void
onCancelTestFlow?: () => void
onOpenPreview?: () => void
onHideJobStatus?: () => void
flowHasChanged?: boolean
}
let {
@@ -146,7 +157,18 @@
allowSimplifiedPoll = true,
expandedSubflows = $bindable({}),
onTestUpTo = undefined,
onEditInput = undefined
onEditInput = undefined,
isOwner = false,
onTestFlow = undefined,
isRunning = false,
onCancelTestFlow = undefined,
onOpenPreview = undefined,
onHideJobStatus = undefined,
individualStepTests = false,
flowJob = undefined,
showJobStatus = false,
suspendStatus = writable({}),
flowHasChanged = false
}: Props = $props()
setContext<{
@@ -308,6 +330,18 @@
},
editInput: (moduleId: string, key: string) => {
onEditInput?.(moduleId, key)
},
testFlow: () => {
onTestFlow?.()
},
cancelTestFlow: () => {
onCancelTestFlow?.()
},
openPreview: () => {
onOpenPreview?.()
},
hideJobStatus: () => {
onHideJobStatus?.()
}
}
@@ -400,7 +434,14 @@
newFlow,
cache,
earlyStop,
editMode
editMode,
isOwner,
isRunning,
individualStepTests,
flowJob,
showJobStatus,
suspendStatus,
flowHasChanged
},
failureModule,
preprocessorModule,
@@ -445,6 +486,11 @@
}
}, 10)
})
let viewportResizer: ViewportResizer | undefined = $state(undefined)
export function isNodeVisible(nodeId: string): boolean {
return viewportResizer?.isNodeVisible(nodeId) ?? false
}
</script>
{#if insertable}
@@ -467,7 +513,7 @@
</div>
{:else}
<SvelteFlowProvider>
<ViewportResizer {width} />
<ViewportResizer {height} {width} {nodes} bind:this={viewportResizer} />
<SvelteFlow
onpaneclick={(e) => {
document.dispatchEvent(new Event('focus'))
@@ -489,7 +535,7 @@
nodesDraggable={false}
--background-color={false}
>
<div class="absolute inset-0 !bg-surface-secondary"></div>
<div class="absolute inset-0 !bg-surface-secondary h-full"></div>
<Controls position="top-right" orientation="horizontal" showLock={false}>
{#if download}
<ControlButton
@@ -1,8 +1,9 @@
<script lang="ts">
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
)
}
</script>
@@ -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<string, GraphModuleState> | 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<Record<string, { job: Job; nb: number }>>
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'
}
@@ -1,12 +1,16 @@
<script lang="ts">
import InsertModuleButton from '$lib/components/flows/map/InsertModuleButton.svelte'
import { getBezierPath, BaseEdge, type EdgeProps, EdgeLabel } from '@xyflow/svelte'
import { ClipboardCopy } from 'lucide-svelte'
import { ClipboardCopy, Hourglass } from 'lucide-svelte'
import { getContext } from 'svelte'
import type { Writable } from 'svelte/store'
import type { GraphEventHandlers } from '../../graphBuilder.svelte'
import { getStraightLinePath } from '../utils'
import { twMerge } from 'tailwind-merge'
import { workspaceStore } from '$lib/stores'
import FlowStatusWaitingForEvents from '$lib/components/FlowStatusWaitingForEvents.svelte'
import type { Job } from '$lib/gen'
import type { GraphModuleState } from '../../model'
const { useDataflow } = getContext<{
useDataflow: Writable<boolean | undefined>
@@ -35,6 +39,10 @@
enableTrigger: boolean
disableAi: boolean
disableMoveIds: string[]
flowModuleStates: Record<string, GraphModuleState> | undefined
isOwner: boolean
flowJob: Job | undefined
suspendStatus?: Writable<Record<string, { job: Job; nb: number }>>
}
} = $props()
@@ -55,10 +63,21 @@
? `${edgePath} ${getStraightLinePath({ sourceX, sourceY, targetY })}`
: edgePath
)
// TODO: this is a hack to show the waiting for events indicator on the edge a proper way would be to have a edge state
// and handle the edge state in the graph builder
let waitingForEvents = $derived(
data?.flowModuleStates?.[data.targetId]?.type === 'WaitingForEvents' ||
data?.flowModuleStates?.[`${data.sourceId}-v`]?.type === 'WaitingForEvents'
)
const suspendStatus: Writable<Record<string, { job: Job; nb: number }>> | undefined = $derived(
data?.suspendStatus
)
</script>
<EdgeLabel x={sourceX} y={sourceY + 28} class="base-edge" style="">
{#if data?.insertable && !$useDataflow && !data?.moving}
{#if data?.insertable && !$useDataflow && !data?.moving && !waitingForEvents}
<div
class={twMerge('edgeButtonContainer nodrag nopan top-0')}
style:transform="translate(-50%, -50%)"
@@ -128,6 +147,42 @@
{/if}
</div>
{/if}
{#if waitingForEvents && data.flowJob && data.flowJob.type === 'QueuedJob'}
<div
class="px-2 py-0.5 rounded-md bg-surface shadow-md text-violet-700 dark:text-violet-400 text-xs flex items-center gap-1"
>
<Hourglass size={12} />
<div class="flex">
<span class="dot">.</span>
<span class="dot">.</span>
<span class="dot">.</span>
</div>
</div>
<div
class={'fixed top-1/2 -translate-y-1/2 left-[170px] h-fit w-fit rounded-md bg-surface flex items-center justify-center p-2 ml-2 shadow-md'}
>
{#if data?.flowJob && data.flowJob.flow_status?.modules?.[data.flowJob.flow_status?.step]?.type === 'WaitingForEvents'}
<FlowStatusWaitingForEvents
job={data.flowJob}
workspaceId={$workspaceStore!}
isOwner={data.isOwner}
light
/>
{:else if $suspendStatus && Object.keys($suspendStatus).length > 0}
<div class="flex gap-2 flex-col">
{#each Object.values($suspendStatus) as suspendCount (suspendCount.job.id)}
<FlowStatusWaitingForEvents
job={suspendCount.job}
workspaceId={$workspaceStore!}
isOwner={data.isOwner}
light
/>
{/each}
</div>
{/if}
</div>
{/if}
</EdgeLabel>
<BaseEdge path={completeEdge} {markerEnd} class={$useDataflow ? 'hidden' : ''} />
@@ -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;
}
}
</style>
@@ -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}
</NodeWrapper>
@@ -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}
/>
<div class="absolute -bottom-10 left-1/2 transform -translate-x-1/2 z-10">
@@ -15,6 +15,10 @@
const { selectedId } = getContext<{
selectedId: Writable<string | undefined>
}>('FlowGraphContext')
const type = $derived(
data.success == undefined ? undefined : data.success ? 'Success' : 'Failure'
)
</script>
<NodeWrapper enableSourceHandle={false}>
@@ -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}
</NodeWrapper>
@@ -0,0 +1,17 @@
import type { Job } from '$lib/gen'
type moduleTestState = {
loading: boolean
cancel?: () => Promise<void>
testJob?: Job
}
export class ModulesTestStates {
states: Record<string, moduleTestState> = $state({})
runTestCb?: (moduleId: string) => void
constructor(runTestCb?: (moduleId: string) => void) {
this.states = {}
this.runTestCb = runTestCb
}
}
@@ -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<FlowStatusViewerContext>('FlowStatusViewer')
interface Props {
job: QueuedJob | CompletedJob
hideJobId?: boolean
extra?: import('svelte').Snippet
}
let { job, hideJobId = false, extra }: Props = $props()
</script>
<div class="grid grid-cols-2 gap-4 mb-1 text-tertiary dark:text-gray-400">
<div
class={twMerge(
'grid grid-cols-2 gap-4 mb-1 text-tertiary dark:text-gray-400',
extra && job && !hideJobId ? 'grid-cols-3' : 'grid-cols-2'
)}
>
<JobStatus {job} />
{#if job && !hideJobId}
<div>
@@ -29,4 +38,5 @@
</div>
</div>
{/if}
{@render extra?.()}
</div>
+1
View File
@@ -379,6 +379,7 @@ export function pointerDownOutside(
event.stopPropagation()
}
node.dispatchEvent(new CustomEvent<PointerEvent>('pointerdown_outside', { detail: event }))
if (typeof options === 'object') options.onClickOutside?.(event)
return false
}
}
+4 -1
View File
@@ -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>('PropPickerContext', {
flowPropPickerConfig: writable<FlowPropPickerConfig | undefined>(undefined),