feat(frontend): run steps from graph (#5915)

This commit is contained in:
Guilhem
2025-06-20 15:39:24 +01:00
committed by GitHub
parent 0a78d1e74e
commit ea30c3a026
34 changed files with 1060 additions and 293 deletions
+4 -3
View File
@@ -48,6 +48,7 @@
import type { FlowPropPickerConfig, PropPickerContext } from './prop_picker'
import type { PickableProperties } from './flows/previousResults'
import { Triggers } from './triggers/triggers.svelte'
import { TestSteps } from './flows/testSteps.svelte'
let flowCopilotContext: FlowCopilotContext = {
shouldUpdatePropertyType: writable<{
@@ -436,7 +437,7 @@
const moving = writable<{ id: string } | undefined>(undefined)
const history = initHistory(flowStore.val)
const testStepStore = writable<Record<string, any>>({})
const testSteps = new TestSteps()
const selectedIdStore = writable('settings-metadata')
const triggersCount = writable<TriggersCount | undefined>(undefined)
@@ -455,7 +456,7 @@
pathStore: writable(''),
flowStateStore,
flowStore,
testStepStore,
testSteps,
saveDraft: () => {},
initialPathStore: writable(''),
fakeInitialPath: '',
@@ -715,7 +716,7 @@
noEditor
on:applyArgs={(ev) => {
if (ev.detail.kind === 'preprocessor') {
$testStepStore['preprocessor'] = ev.detail.args ?? {}
testSteps.setStepArgs('preprocessor', ev.detail.args ?? {})
$selectedIdStore = 'preprocessor'
} else {
previewArgsStore.val = ev.detail.args ?? {}
+23 -3
View File
@@ -72,6 +72,7 @@
} from './triggers/utils'
import DraftTriggersConfirmationModal from './common/confirmationModal/DraftTriggersConfirmationModal.svelte'
import { Triggers } from './triggers/triggers.svelte'
import { TestSteps } from './flows/testSteps.svelte'
import { aiChatManager } from './copilot/chat/AIChatManager.svelte'
interface Props {
@@ -526,7 +527,7 @@
payloadData: undefined
})
const testStepStore = writable<Record<string, any>>({})
const testSteps = new TestSteps()
function select(selectedId: string) {
selectedIdStore.set(selectedId)
@@ -544,7 +545,7 @@
flowStateStore,
flowStore,
pathStore,
testStepStore,
testSteps,
saveDraft,
initialPathStore,
fakeInitialPath,
@@ -766,6 +767,9 @@
let flowPreviewButtons: FlowPreviewButtons | undefined = $state()
let forceTestTab: Record<string, boolean> = $state({})
let highlightArg: Record<string, string | undefined> = $state({})
run(() => {
initialPathStore.set(initialPath)
})
@@ -1020,7 +1024,7 @@
{newFlow}
on:applyArgs={(ev) => {
if (ev.detail.kind === 'preprocessor') {
$testStepStore['preprocessor'] = ev.detail.args ?? {}
testSteps.setStepArgs('preprocessor', ev.detail.args ?? {})
$selectedIdStore = 'preprocessor'
}
}}
@@ -1028,8 +1032,24 @@
previewArgsStore.val = JSON.parse(JSON.stringify(e.detail))
flowPreviewButtons?.openPreview(true)
}}
onTestUpTo={() => {
flowPreviewButtons?.testUpTo()
}}
{savedFlow}
onDeployTrigger={handleDeployTrigger}
onEditInput={(moduleId, key) => {
selectedIdStore.set(moduleId)
// Use new prop-based system
forceTestTab[moduleId] = true
highlightArg[moduleId] = key
// Reset the force flag after a short delay to allow re-triggering
setTimeout(() => {
forceTestTab[moduleId] = false
highlightArg[moduleId] = undefined
}, 500)
}}
{forceTestTab}
{highlightArg}
/>
{:else}
<CenteredPage>Loading...</CenteredPage>
@@ -379,7 +379,14 @@
id="flow-editor-test-flow-drawer"
shortCut={{ Icon: CornerDownLeft }}
>
Test flow
{#if previewMode == 'upTo'}
Test up to
<Badge baseClass="ml-1" color="indigo">
{$selectedId}
</Badge>
{:else}
Test flow
{/if}
</Button>
</div>
{/if}
@@ -1,22 +1,13 @@
<script lang="ts">
import { run } from 'svelte/legacy'
import type { Schema } from '$lib/common'
import { ScriptService, type FlowModule, type Job } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { getScriptByPath } from '$lib/scripts'
import { type FlowModule, type Job } from '$lib/gen'
import { CornerDownLeft, Loader2 } from 'lucide-svelte'
import { getContext } from 'svelte'
import Button from './common/button/Button.svelte'
import type { FlowEditorContext } from './flows/types'
import TestJobLoader from './TestJobLoader.svelte'
import ModulePreviewForm from './ModulePreviewForm.svelte'
import { evalValue } from './flows/utils'
import type { PickableProperties } from './flows/previousResults'
import ModuleTest from './ModuleTest.svelte'
import { getContext } from 'svelte'
import type { FlowEditorContext } from './flows/types'
interface Props {
mod: FlowModule
@@ -26,6 +17,7 @@
testIsLoading?: boolean
noEditor?: boolean
scriptProgress?: any
focusArg?: string
}
let {
@@ -35,90 +27,25 @@
testJob = $bindable(undefined),
testIsLoading = $bindable(false),
noEditor = false,
scriptProgress = $bindable(undefined)
scriptProgress = $bindable(undefined),
focusArg = undefined
}: Props = $props()
const { flowStore, flowStateStore, testStepStore, pathStore } =
getContext<FlowEditorContext>('FlowEditorContext')
// Test
let testJobLoader: TestJobLoader | undefined = $state()
let jobProgressReset: () => void = () => {}
let stepArgs: Record<string, any> | undefined = $state(
Object.fromEntries(
Object.keys(schema.properties ?? {}).map((k) => [
k,
evalValue(k, mod, $testStepStore, pickableProperties, false)
])
)
)
run(() => {
$testStepStore[mod.id] = stepArgs
})
const { flowStore } = getContext<FlowEditorContext>('FlowEditorContext')
let moduleTest: ModuleTest | undefined = $state()
export function runTestWithStepArgs() {
runTest(stepArgs)
}
export async function runTest(args: any) {
// Not defined if JobProgressBar not loaded
if (jobProgressReset) jobProgressReset()
const val = mod.value
// let jobId: string | undefined = undefined
if (val.type == 'rawscript') {
await testJobLoader?.runPreview(
val.path ?? ($pathStore ?? '') + '/' + mod.id,
val.content,
val.language,
mod.id === 'preprocessor' ? { _ENTRYPOINT_OVERRIDE: 'preprocessor', ...args } : args,
flowStore.val?.tag ?? val.tag
)
} else if (val.type == 'script') {
const script = val.hash
? await ScriptService.getScriptByHash({ workspace: $workspaceStore!, hash: val.hash })
: await getScriptByPath(val.path)
await testJobLoader?.runPreview(
val.path,
script.content,
script.language,
mod.id === 'preprocessor' ? { _ENTRYPOINT_OVERRIDE: 'preprocessor', ...args } : args,
flowStore.val?.tag ?? (val.tag_override ? val.tag_override : script.tag),
script.lock,
val.hash ?? script.hash
)
} else if (val.type == 'flow') {
await testJobLoader?.runFlowByPath(val.path, args)
} else {
throw Error('Not supported module type')
}
}
function jobDone() {
if (testJob && !testJob.canceled && testJob.type == 'CompletedJob' && `result` in testJob) {
if ($flowStateStore[mod.id]) {
$flowStateStore[mod.id].previewResult = testJob.result
$flowStateStore[mod.id].previewSuccess = testJob.success
$flowStateStore[mod.id].previewJobId = testJob.id
$flowStateStore[mod.id].previewWorkspaceId = testJob.workspace_id
$flowStateStore = $flowStateStore
}
}
testJob = undefined
moduleTest?.runTestWithStepArgs()
}
</script>
<TestJobLoader
toastError={noEditor}
on:done={() => jobDone()}
<ModuleTest
{mod}
{noEditor}
bind:testJob
bind:testIsLoading
bind:scriptProgress
bind:this={testJobLoader}
bind:isLoading={testIsLoading}
bind:job={testJob}
bind:this={moduleTest}
/>
<div class="p-4">
@@ -130,7 +57,7 @@
<div class="w-full justify-center flex">
{#if testIsLoading}
<Button size="sm" on:click={testJobLoader?.cancelJob} btnClasses="w-full" color="red">
<Button size="sm" on:click={moduleTest?.cancelJob} btnClasses="w-full" color="red">
<Loader2 size={16} class="animate-spin mr-1" />
Cancel
</Button>
@@ -139,7 +66,7 @@
color="dark"
btnClasses="truncate"
size="sm"
on:click={() => runTest(stepArgs)}
on:click={runTestWithStepArgs}
shortCut={{
Icon: CornerDownLeft
}}
@@ -149,5 +76,5 @@
{/if}
</div>
<ModulePreviewForm {pickableProperties} {mod} {schema} bind:args={stepArgs} />
<ModulePreviewForm {pickableProperties} {mod} {schema} {focusArg} />
</div>
@@ -5,77 +5,91 @@
import { RefreshCw } from 'lucide-svelte'
import ArgInput from './ArgInput.svelte'
import { Button } from './common'
import { getContext, untrack } from 'svelte'
import { getContext, onMount, untrack } from 'svelte'
import type { FlowEditorContext } from './flows/types'
import { evalValue } from './flows/utils'
import type { FlowModule } from '$lib/gen'
import type { PickableProperties } from './flows/previousResults'
import type SimpleEditor from './SimpleEditor.svelte'
import { getResourceTypes } from './resourceTypesStore'
import { twMerge } from 'tailwind-merge'
interface Props {
schema: Schema | { properties?: Record<string, any>; required?: string[] }
args?: Record<string, any>
mod: FlowModule
pickableProperties: PickableProperties | undefined
isValid?: boolean
autofocus?: boolean
focusArg?: string
}
let {
schema,
args = $bindable({}),
mod,
pickableProperties,
isValid = $bindable(true),
autofocus = false
autofocus = false,
focusArg = undefined
}: Props = $props()
const { testStepStore } = getContext<FlowEditorContext>('FlowEditorContext')
const { testSteps, flowStateStore, flowStore, previewArgs } =
getContext<FlowEditorContext>('FlowEditorContext')
let inputCheck: { [id: string]: boolean } = $state({})
$effect(() => {
isValid = allTrue(inputCheck) ?? false
})
$effect(() => {
if (args == undefined || typeof args !== 'object') {
args = {}
}
})
function removeExtraKey() {
const nargs = {}
Object.keys(args ?? {}).forEach((key) => {
if (keys.includes(key)) {
nargs[key] = args[key]
}
})
args = nargs
}
let keys: string[] = $state([])
$effect(() => {
let lkeys = Object.keys(schema?.properties ?? {})
if (schema?.properties && JSON.stringify(lkeys) != JSON.stringify(keys)) {
keys = lkeys
untrack(() => removeExtraKey())
untrack(() => testSteps?.removeExtraKey(mod.id, keys))
}
})
function plugIt(argName: string) {
args[argName] = structuredClone(
$state.snapshot(evalValue(argName, mod, testStepStore, pickableProperties, true))
testSteps?.setEvaluatedStepArg(
mod.id,
argName,
$state.snapshot(evalValue(argName, mod, pickableProperties, true))
)
try {
editor?.[argName]?.setCode(JSON.stringify(args[argName], null, 4))
} catch {
//ignore
}
}
let editor: Record<string, SimpleEditor | undefined> = $state({})
// Animation and highlighting for focusArg
let animateArg: string | undefined = $state(undefined)
$effect(() => {
if (focusArg) {
// Add a slight delay to ensure the form is rendered
setTimeout(() => {
const argElement = document.querySelector(`[data-arg="${focusArg}"]`)
if (argElement) {
// Add highlight animation
animateArg = focusArg
argElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
// Focus the input if it exists
const input = argElement.querySelector('input, textarea, select') as
| HTMLInputElement
| HTMLTextAreaElement
| HTMLSelectElement
| null
if (input) {
input.focus()
}
// Remove highlight after animation
setTimeout(() => {
animateArg = undefined
}, 2000)
}
}, 200)
}
})
let resourceTypes: string[] | undefined = $state(undefined)
async function loadResourceTypes() {
@@ -83,21 +97,34 @@
}
loadResourceTypes()
let args = $state(<Record<string, any>>{})
onMount(() => {
testSteps?.updateStepArgs(mod.id, $flowStateStore, flowStore?.val, previewArgs?.val)
args = testSteps?.getStepArgs(mod.id) ?? { value: {} }
})
</script>
<div class="w-full pt-2">
<div class="w-full pt-2" data-popover>
{#if keys.length > 0}
{#each keys as argName, i (argName)}
{#if Object.keys(schema.properties ?? {}).includes(argName)}
<div class="flex gap-2">
{#if typeof args == 'object' && schema?.properties?.[argName]}
<div
class={twMerge(
'flex gap-2',
animateArg === argName && 'animate-pulse ring-2 ring-offset-2 ring-blue-500 rounded'
)}
data-arg={argName}
>
{#if typeof args.value == 'object' && schema?.properties?.[argName]}
<ArgInput
{resourceTypes}
minW={false}
autofocus={i == 0 && autofocus}
autofocus={autofocus && !focusArg && i == 0}
label={argName}
description={schema.properties[argName].description}
bind:value={args[argName]}
bind:value={args.value[argName]}
type={schema.properties[argName].type}
oneOf={schema.properties[argName].oneOf}
required={schema?.required?.includes(argName)}
@@ -117,15 +144,19 @@
placeholder={schema.properties[argName].placeholder}
/>
{/if}
<div class="pt-6 mt-0.5">
<Button
on:click={() => plugIt(argName)}
size="sm"
variant="border"
color="light"
title="Re-evaluate input step"><RefreshCw size={14} /></Button
>
</div>
{#if testSteps?.isArgManuallySet(mod.id, argName)}
<div class="pt-6 mt-0.5">
<Button
on:click={() => {
plugIt(argName)
}}
size="sm"
variant="border"
color="light"
title="Re-evaluate input step"><RefreshCw size={14} /></Button
>
</div>
{/if}
</div>
{/if}
{/each}
@@ -41,7 +41,7 @@
onUpdateMock
}: Props = $props()
const { testStepStore } = getContext<FlowEditorContext>('FlowEditorContext')
const { testSteps } = getContext<FlowEditorContext>('FlowEditorContext')
let selectedJob: Job | undefined = $state(undefined)
let fetchingLastJob = false
@@ -49,7 +49,7 @@
let jobProgressReset: () => void = $state(() => {})
let nlastJob = $derived.by(() => {
if (testJob) {
if (testJob && testJob.type === 'CompletedJob') {
return { ...testJob, preview: true }
}
if (lastJob) {
@@ -90,7 +90,7 @@
{disableHistory}
>
{#snippet copilot_fix()}
{#if lang && editor && diffEditor && $testStepStore[mod.id] && selectedJob?.type === 'CompletedJob' && !selectedJob.success && getStringError(selectedJob.result)}
{#if lang && editor && diffEditor && testSteps.getStepArgs(mod.id) && selectedJob?.type === 'CompletedJob' && !selectedJob.success && getStringError(selectedJob.result)}
<ScriptFix {lang} />
{/if}
{/snippet}
@@ -0,0 +1,137 @@
<script lang="ts" module>
type testModuleState = {
loading: boolean
instances: number
cancel?: () => 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 type { FlowEditorContext } from './flows/types'
import TestJobLoader from './TestJobLoader.svelte'
interface Props {
mod: FlowModule
testJob?: Job | undefined
testIsLoading?: boolean
noEditor?: boolean
scriptProgress?: any
}
let {
mod,
testJob = $bindable(undefined),
testIsLoading = $bindable(false),
noEditor = false,
scriptProgress = $bindable(undefined)
}: Props = $props()
const { flowStore, flowStateStore, pathStore, testSteps, previewArgs } =
getContext<FlowEditorContext>('FlowEditorContext')
let testJobLoader: TestJobLoader | undefined = $state(undefined)
let jobProgressReset: () => void = () => {}
export function runTestWithStepArgs() {
runTest(testSteps.getStepArgs(mod.id)?.value)
}
export function loadArgsAndRunTest() {
testSteps?.updateStepArgs(mod.id, $flowStateStore, flowStore?.val, previewArgs?.val)
runTest(testSteps.getStepArgs(mod.id)?.value)
}
export async function runTest(args: any) {
// Not defined if JobProgressBar not loaded
if (jobProgressReset) jobProgressReset()
testModulesState[mod.id].cancel = testJobLoader?.cancelJob
const val = mod.value
// let jobId: string | undefined = undefined
if (val.type == 'rawscript') {
await testJobLoader?.runPreview(
val.path ?? ($pathStore ?? '') + '/' + mod.id,
val.content,
val.language,
mod.id === 'preprocessor' ? { _ENTRYPOINT_OVERRIDE: 'preprocessor', ...args } : args,
flowStore?.val?.tag ?? val.tag
)
} else if (val.type == 'script') {
const script = val.hash
? await ScriptService.getScriptByHash({ workspace: $workspaceStore!, hash: val.hash })
: await getScriptByPath(val.path)
await testJobLoader?.runPreview(
val.path,
script.content,
script.language,
mod.id === 'preprocessor' ? { _ENTRYPOINT_OVERRIDE: 'preprocessor', ...args } : args,
flowStore?.val?.tag ?? (val.tag_override ? val.tag_override : script.tag),
script.lock,
val.hash ?? script.hash
)
} else if (val.type == 'flow') {
await testJobLoader?.runFlowByPath(val.path, args)
} else {
throw Error('Not supported module type')
}
}
function jobDone() {
if (testJob && !testJob.canceled && testJob.type == 'CompletedJob' && `result` in testJob) {
if ($flowStateStore[mod.id]) {
$flowStateStore[mod.id].previewResult = testJob.result
$flowStateStore[mod.id].previewSuccess = testJob.success
$flowStateStore[mod.id].previewJobId = testJob.id
$flowStateStore[mod.id].previewWorkspaceId = testJob.workspace_id
$flowStateStore = $flowStateStore
}
}
testJob = undefined
}
export function cancelJob() {
testModulesState[mod.id]?.cancel?.()
}
$effect(() => {
testIsLoading = testModulesState[mod.id]?.loading ?? false
})
onMount(() => {
testModulesState[mod.id] = {
...(testModulesState[mod.id] ?? { loading: false, instances: 0 }),
loading: testIsLoading,
instances: testModulesState[mod.id]!.instances + 1
}
return () => {
testModulesState[mod.id]!.instances -= 1
if (testModulesState[mod.id]!.instances < 1) {
delete testModulesState[mod.id]
}
}
})
</script>
<TestJobLoader
toastError={noEditor}
on:done={() => jobDone()}
bind:scriptProgress
bind:this={testJobLoader}
bind:isLoading={
() => testModulesState[mod.id]?.loading ?? false,
(v) =>
(testModulesState[mod.id] = {
...testModulesState[mod.id],
loading: v ?? false,
instances: testModulesState[mod.id]?.instances ?? 0
})
}
bind:job={testJob}
/>
@@ -49,6 +49,7 @@
portal?: string
}
| undefined = undefined
export let dropdownBtnClasses: string = ''
type MenuItem = {
label: string
@@ -309,7 +310,8 @@
'rounded-md m-0 p-0 center-center h-full',
variant === 'border' ? 'border-0 border-r border-y ' : 'border-0',
'rounded-r-md !rounded-l-none',
size === 'xs2' ? '!w-8' : '!w-10'
size === 'xs2' ? '!w-8' : '!w-10',
dropdownBtnClasses
)}
>
<ChevronDown size={lucideIconSize} />
@@ -32,6 +32,10 @@
})
| undefined
onDeployTrigger?: (trigger: Trigger) => void
onTestUpTo?: ((id: string) => void) | undefined
onEditInput?: ((moduleId: string, key: string) => void) | undefined
forceTestTab?: Record<string, boolean>
highlightArg?: Record<string, string | undefined>
}
let {
@@ -44,7 +48,11 @@
smallErrorHandler = false,
newFlow = false,
savedFlow = undefined,
onDeployTrigger = () => {}
onDeployTrigger = () => {},
onTestUpTo = undefined,
onEditInput = undefined,
forceTestTab,
highlightArg
}: Props = $props()
let flowModuleSchemaMap: FlowModuleSchemaMap | undefined = $state()
@@ -93,6 +101,8 @@
}
aiChatManager.generateStep(detail.moduleId, detail.lang, detail.instructions)
}}
{onTestUpTo}
{onEditInput}
/>
{/if}
</div>
@@ -113,6 +123,8 @@
on:applyArgs
on:testWithArgs
{onDeployTrigger}
{forceTestTab}
{highlightArg}
/>
{/if}
</Pane>
@@ -25,6 +25,8 @@
})
| undefined
onDeployTrigger?: (trigger: Trigger) => void
forceTestTab?: Record<string, boolean>
highlightArg?: Record<string, string | undefined>
}
let {
@@ -33,7 +35,9 @@
newFlow = false,
disabledFlowInputs = false,
savedFlow = undefined,
onDeployTrigger = () => {}
onDeployTrigger = () => {},
forceTestTab,
highlightArg
}: Props = $props()
const {
@@ -138,6 +142,8 @@
previousModule={flowStore.val.value.modules[index - 1]}
{enableAi}
savedModule={savedFlow?.value.modules[index]}
{forceTestTab}
{highlightArg}
/>
{/each}
{/key}
@@ -77,6 +77,8 @@
noEditor: boolean
enableAi: boolean
savedModule?: FlowModule | undefined
forceTestTab?: boolean
highlightArg?: string
}
let {
@@ -89,7 +91,9 @@
scriptTemplate = 'script',
noEditor,
enableAi,
savedModule = undefined
savedModule = undefined,
forceTestTab = false,
highlightArg = undefined
}: Props = $props()
let tag: string | undefined = $state(undefined)
@@ -274,6 +278,20 @@
onDestroy(() => {
$currentEditor = undefined
})
// Handle force test tab prop with animation
$effect(() => {
if (forceTestTab) {
selected = 'test'
// Add a smooth transition to the test tab
setTimeout(() => {
const testTab = document.querySelector('[value="test"]')
if (testTab) {
testTab.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}
}, 100)
}
})
</script>
<svelte:window onkeydown={onKeyDown} />
@@ -525,6 +543,7 @@
bind:testJob
bind:testIsLoading
bind:scriptProgress
focusArg={highlightArg}
/>
{:else if selected === 'advanced'}
<Tabs bind:selected={advancedSelected}>
@@ -40,6 +40,8 @@
parentModule?: FlowModule | undefined
// Pointer to previous module, for easy access to testing results
previousModule?: FlowModule | undefined
forceTestTab?: Record<string, boolean>
highlightArg?: Record<string, string | undefined>
}
let {
@@ -48,7 +50,9 @@
enableAi = false,
savedModule = undefined,
parentModule = $bindable(),
previousModule = undefined
previousModule = undefined,
forceTestTab,
highlightArg
}: Props = $props()
function initializePrimaryScheduleForTriggerScript(module: FlowModule) {
@@ -196,6 +200,8 @@
{scriptTemplate}
{enableAi}
{savedModule}
forceTestTab={forceTestTab?.[flowModule.id]}
highlightArg={highlightArg?.[flowModule.id]}
/>
{/if}
{:else if flowModule.value.type === 'forloopflow' || flowModule.value.type == 'whileloopflow'}
@@ -210,6 +216,8 @@
? savedModule.value.modules[index]
: undefined}
{enableAi}
{forceTestTab}
{highlightArg}
/>
{/each}
{:else if flowModule.value.type === 'branchone'}
@@ -229,6 +237,8 @@
? savedModule.value.default[index]
: undefined}
{enableAi}
{forceTestTab}
{highlightArg}
/>
{/each}
{/if}
@@ -252,6 +262,8 @@
? savedModule.value.branches[branchIndex]?.modules[index]
: undefined}
{enableAi}
{forceTestTab}
{highlightArg}
/>
{/each}
{/if}
@@ -271,6 +283,8 @@
savedModule={savedModule?.value.type === 'branchall'
? savedModule.value.branches[branchIndex]?.modules[index]
: undefined}
{forceTestTab}
{highlightArg}
/>
{/each}
{/if}
@@ -70,6 +70,12 @@
'triggers'
].includes($selectedId) ||
$selectedId?.includes('branch')
export function testUpTo() {
if (upToDisabled) return
previewMode = 'upTo'
previewOpen = true
}
</script>
{#if !upToDisabled}
@@ -78,10 +84,7 @@
disabled={upToDisabled}
color="light"
variant="border"
on:click={() => {
previewMode = 'upTo'
previewOpen = !previewOpen
}}
on:click={testUpTo}
startIcon={{ icon: Play }}
>
Test up to&nbsp;
@@ -14,7 +14,9 @@
Square,
SkipForward,
Pin,
X
X,
Play,
Loader2
} from 'lucide-svelte'
import { createEventDispatcher, getContext, untrack } from 'svelte'
import { fade } from 'svelte/transition'
@@ -23,6 +25,7 @@
import { twMerge } from 'tailwind-merge'
import IdEditorInput from '$lib/components/IdEditorInput.svelte'
import { dfs } from '../dfs'
import { dfs as dfsPreviousResults } from '../previousResults'
import { Drawer } from '$lib/components/common'
import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte'
import { getDependeeAndDependentComponents } from '../flowExplorer'
@@ -32,6 +35,8 @@
import OutputPicker from '$lib/components/flows/propPicker/OutputPicker.svelte'
import OutputPickerInner from '$lib/components/flows/propPicker/OutputPickerInner.svelte'
import type { FlowState } from '$lib/components/flows/flowState'
import { Button } from '$lib/components/common'
import ModuleTest from '$lib/components/ModuleTest.svelte'
interface Props {
selected?: boolean
@@ -63,7 +68,10 @@
alwaysShowOutputPicker?: boolean
loopStatus?: { type: 'inside' | 'self'; flow: 'forloopflow' | 'whileloopflow' } | undefined
icon?: import('svelte').Snippet
onTestUpTo?: ((id: string) => void) | undefined
inputTransform?: Record<string, any> | undefined
onUpdateMock?: (mock: { enabled: boolean; return_value?: unknown }) => void
onEditInput?: (moduleId: string, key: string) => void
}
let {
@@ -91,7 +99,10 @@
alwaysShowOutputPicker = false,
loopStatus = undefined,
icon,
onUpdateMock
onTestUpTo,
inputTransform,
onUpdateMock,
onEditInput
}: Props = $props()
let pickableIds: Record<string, any> | undefined = $state(undefined)
@@ -116,11 +127,15 @@
let newId: string = $state(id ?? '')
let moduleTest: ModuleTest | undefined = $state(undefined)
let testIsLoading = $state(false)
let hover = $state(false)
let connectingData: any | undefined = $state(undefined)
let lastJob: any | undefined = $state(undefined)
let outputPicker: OutputPicker | undefined = $state(undefined)
let historyOpen = $state(false)
let testJob: any | undefined = $state(undefined)
let outputPickerBarOpen = $state(false)
let flowStateStore = $derived(flowEditorContext?.flowStateStore)
@@ -158,10 +173,24 @@
flowStateStore && $flowStateStore && untrack(() => updateLastJob($flowStateStore))
})
let nlastJob = $derived.by(() => {
if (testJob) {
return { ...testJob, preview: true }
}
if (lastJob) {
return { ...lastJob, preview: false }
}
return undefined
})
let isConnectingCandidate = $derived(
!!id && !!$flowPropPickerConfig && !!pickableIds && Object.keys(pickableIds).includes(id)
)
const outputPickerVisible = $derived(
editMode && (isConnectingCandidate || alwaysShowOutputPicker) && !!id
)
const icon_render = $derived(icon)
</script>
@@ -223,22 +252,36 @@
</Drawer>
{/if}
{#if deletable && id && flowEditorContext?.flowStore && outputPickerVisible}
{@const flowStore = flowEditorContext?.flowStore.val}
{@const mod = flowStore?.value ? dfsPreviousResults(id, flowStore, false)[0] : undefined}
{#if mod && $flowStateStore[id]}
<ModuleTest bind:this={moduleTest} {mod} bind:testIsLoading bind:testJob />
{/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 outline-offset-0 outline-slate-500 dark:outline-gray-400',
selected ? 'outline outline-2' : 'active:outline active:outline-2',
'flex relative'
)}
style="width: 275px; height: 38px; background-color: {hover && bgHoverColor
class={classNames('w-full module flex rounded-sm cursor-pointer max-w-full ', 'flex relative')}
style="width: 275px; height: 34px; background-color: {hover && bgHoverColor
? bgHoverColor
: bgColor};"
onmouseenter={() => (hover = true)}
onmouseleave={() => (hover = false)}
onpointerdown={stopPropagation(preventDefault(() => dispatch('pointerdown')))}
>
<div class="absolute text-sm right-12 -bottom-3 flex flex-row gap-1 z-10">
<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'}`}
>
{#if retry}
<Popover notClickable>
<div
@@ -358,13 +401,18 @@
{/snippet}
</FlowModuleSchemaItemViewer>
{#if editMode && (isConnectingCandidate || alwaysShowOutputPicker)}
{#if outputPickerVisible}
<OutputPicker
bind:this={outputPicker}
{selected}
{hover}
{isConnectingCandidate}
{historyOpen}
{inputTransform}
id={id ?? ''}
bind:bottomBarOpen={outputPickerBarOpen}
{loopStatus}
{onEditInput}
>
{#snippet children({ allowCopy, isConnecting, selectConnection })}
<OutputPickerInner
@@ -372,9 +420,9 @@
prefix={'results'}
connectingData={isConnecting ? connectingData : undefined}
{mock}
{lastJob}
onSelect={selectConnection}
lastJob={nlastJob}
moduleId={id}
onSelect={selectConnection}
{onUpdateMock}
{path}
{loopStatus}
@@ -382,6 +430,7 @@
bind:derivedHistoryOpen={historyOpen}
historyOffset={{ mainAxis: 12, crossAxis: -9 }}
clazz="p-1"
isLoading={testIsLoading}
/>
{/snippet}
</OutputPicker>
@@ -389,10 +438,60 @@
</div>
{#if deletable}
<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}
</div>
{/if}
</div>
<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'}"
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 }))
@@ -48,6 +48,8 @@
newFlow?: boolean
smallErrorHandler?: boolean
workspace?: string | undefined
onTestUpTo?: ((id: string) => void) | undefined
onEditInput?: (moduleId: string, key: string) => void
}
let {
@@ -58,7 +60,9 @@
disableSettings = false,
newFlow = false,
smallErrorHandler = false,
workspace = $workspaceStore
workspace = $workspaceStore,
onTestUpTo,
onEditInput
}: Props = $props()
let flowTutorials: FlowTutorials | undefined = $state(undefined)
@@ -343,6 +347,8 @@
{flowInputsStore}
{workspace}
editMode
{onTestUpTo}
{onEditInput}
onDelete={(id) => {
dependents = getDependentComponents(id, flowStore.val)
const cb = () => {
@@ -39,10 +39,12 @@
editMode?: boolean
onSelectedIteration: onSelectedIteration
onSelect: (id: string | FlowModule) => void
onTestUpTo?: ((id: string) => void) | undefined
onUpdateMock?: (detail: {
id: string
mock: { enabled: boolean; return_value?: unknown }
}) => void
onEditInput?: (moduleId: string, key: string) => void
}
let {
@@ -59,7 +61,9 @@
flowJobs,
editMode = false,
onSelect,
onUpdateMock
onTestUpTo,
onUpdateMock,
onEditInput
}: Props = $props()
const { selectedId } = getContext<{
@@ -155,6 +159,7 @@
: ''}
alwaysShowOutputPicker={!mod.id.startsWith('subflow:')}
loopStatus={{ type: 'self', flow: mod.value.type }}
{onTestUpTo}
>
{#snippet icon()}
<div>
@@ -175,6 +180,7 @@
label={mod.summary || 'Run one branch'}
{bgColor}
{bgHoverColor}
{onTestUpTo}
>
{#snippet icon()}
<div>
@@ -195,6 +201,7 @@
label={mod.summary || `Run all branches${mod.value.parallel ? ' (parallel)' : ''}`}
{bgColor}
{bgHoverColor}
{onTestUpTo}
>
{#snippet icon()}
<div>
@@ -236,6 +243,9 @@
isTrigger={isTriggerStep(mod)}
alwaysShowOutputPicker={!mod.id.startsWith('subflow:') && mod.id !== 'preprocessor'}
loopStatus={parentLoop ? { type: 'inside', flow: parentLoop.type } : undefined}
inputTransform={mod.value.type !== 'identity' ? mod.value.input_transforms : undefined}
{onTestUpTo}
{onEditInput}
>
{#snippet icon()}
<div>
@@ -27,6 +27,7 @@
editMode?: boolean
icon?: import('svelte').Snippet
onUpdateMock?: (mock: { enabled: boolean; return_value?: unknown }) => void
onEditInput?: (moduleId: string, key: string) => void
}
let {
@@ -47,11 +48,25 @@
earlyStop = false,
editMode = false,
icon,
onUpdateMock
onUpdateMock,
onEditInput
}: Props = $props()
const outputPickerVisible = $derived(
(alwaysPluggable || (inputJson && Object.keys(inputJson).length > 0)) && editMode
)
</script>
<VirtualItemWrapper {label} {bgColor} {bgHoverColor} {selected} {selectable} {id} on:select>
<VirtualItemWrapper
{label}
{bgColor}
{bgHoverColor}
{selected}
{selectable}
{id}
outputPickerVisible={outputPickerVisible ?? false}
on:select
>
{#snippet children({ hover })}
<div class="flex flex-col w-full">
<div
@@ -80,8 +95,14 @@
</div>
{/if}
</div>
{#if (alwaysPluggable || (inputJson && Object.keys(inputJson).length > 0)) && editMode}
<OutputPicker {selected} {hover} isConnectingCandidate={true} variant="virtual">
{#if outputPickerVisible}
<OutputPicker
{selected}
{hover}
id={id ?? ''}
isConnectingCandidate={true}
variant="virtual"
>
{#snippet children({ allowCopy, isConnecting, selectConnection })}
<OutputPickerInner
{allowCopy}
@@ -95,6 +116,8 @@
rightMargin
historyOffset={{ mainAxis: 12, crossAxis: -9 }}
clazz="p-1"
{onEditInput}
selectionId={id ?? label ?? ''}
/>
{/snippet}
</OutputPicker>
@@ -2,6 +2,7 @@
import type { FlowModule } from '$lib/gen'
import { classNames } from '$lib/utils'
import { createEventDispatcher } from 'svelte'
import { twMerge } from 'tailwind-merge'
interface Props {
label: string | undefined
@@ -12,6 +13,7 @@
bgColor: string
bgHoverColor?: string
children?: import('svelte').Snippet<[any]>
outputPickerVisible?: boolean
}
let {
@@ -22,7 +24,8 @@
onTop = false,
bgColor,
bgHoverColor = '',
children
children,
outputPickerVisible = false
}: Props = $props()
const dispatch = createEventDispatcher<{
@@ -41,14 +44,8 @@
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class={classNames(
'w-full flex relative rounded-sm',
selectable ? 'cursor-pointer active:outline active:outline-2' : '',
selected ? 'outline outline-2' : '',
onTop ? 'z-[901]' : '',
'outline-offset-1 outline-gray-600 dark:outline-gray-400'
)}
style="width: 275px; max-height: 38px; background-color: {hover && bgHoverColor && selectable
class={classNames('w-full flex relative rounded-sm', onTop ? 'z-[901]' : '')}
style="width: 275px; max-height: 34px; background-color: {hover && bgHoverColor && selectable
? bgHoverColor
: bgColor};"
onpointerdown={() => {
@@ -64,5 +61,15 @@
}}
title={label ? label + ' ' : ''}
id={`flow-editor-virtual-${encodeURIComponent(label || label || '')}`}
>{@render children?.({ hover })}</div
>
<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>
@@ -88,7 +88,10 @@ function getFlowInput(
}
} else {
let parentFlowInput = getFlowInput(parentModules, flowState, args, schema)
if (parentModule.value.type === 'forloopflow' || parentModule.value.type === 'whileloopflow') {
if (
parentModule.value.type === 'forloopflow' ||
parentModule.value.type === 'whileloopflow'
) {
let parentFlowInputIter = { ...parentFlowInput }
if (parentFlowInputIter.hasOwnProperty('iter')) {
parentFlowInputIter['iter_parent'] = parentFlowInputIter['iter']
@@ -205,8 +208,8 @@ export function getStepPropPicker(
return [
id,
module?.mock?.enabled
? module.mock.return_value ?? {}
: flowState[id]?.previewResult ?? {}
? (module.mock.return_value ?? {})
: (flowState[id]?.previewResult ?? {})
]
})
.reverse()
@@ -323,3 +326,46 @@ export function filterNestedObject(obj: any, nestedKeys: string[]) {
}
return {}
}
/**
* Get the ID of the previous module within the same container (loop or branch)
* based on the same logic used in FlowModuleWrapper.svelte
*/
export function getPreviousModule(moduleId: string, flow: OpenFlow): FlowModule | undefined {
function searchInModules(modules: FlowModule[]): FlowModule | undefined | null {
for (let i = 0; i < modules.length; i++) {
const module = modules[i]
if (module.id === moduleId) {
// Found the module, return previous module ID if it exists
return i > 0 ? modules[i - 1] : undefined
}
// Search in submodules based on module type
if (module.value.type === 'forloopflow' || module.value.type === 'whileloopflow') {
const result = searchInModules(module.value.modules)
if (result !== null) return result
} else if (module.value.type === 'branchone') {
// Search in default branch
const defaultResult = searchInModules(module.value.default)
if (defaultResult !== null) return defaultResult
// Search in each branch
for (const branch of module.value.branches) {
const branchResult = searchInModules(branch.modules)
if (branchResult !== null) return branchResult
}
} else if (module.value.type === 'branchall') {
// Search in each branch
for (const branch of module.value.branches) {
const branchResult = searchInModules(branch.modules)
if (branchResult !== null) return branchResult
}
}
}
return null // Continue searching (module not found in this branch)
}
const result = searchInModules(flow.value.modules)
return result === null ? undefined : result
}
@@ -0,0 +1,66 @@
<script lang="ts">
import type { FlowEditorContext } from '../types'
import { getContext, onMount } from 'svelte'
import ObjectViewer from '$lib/components/propertyPicker/ObjectViewer.svelte'
import { twMerge } from 'tailwind-merge'
import { DollarSign, Pencil, RefreshCw, SquareFunction } from 'lucide-svelte'
interface Props {
inputTransform: Record<string, any> | undefined
id: string
onEditInput?: (moduleId: string, key: string) => void
}
let { inputTransform, id, onEditInput }: Props = $props()
const { flowStore, flowStateStore, testSteps, previewArgs } =
getContext<FlowEditorContext | undefined>('FlowEditorContext') || {}
onMount(() => {
testSteps?.updateStepArgs(id, $flowStateStore, flowStore?.val, previewArgs?.val)
})
const input = $derived(testSteps?.getStepArgs(id)?.value)
</script>
<div class="p-4 pr-6 h-full overflow-y-auto">
<ObjectViewer json={input} {inputTransform} {metaData} {editKey} />
</div>
{#snippet metaData(key: string)}
{#if inputTransform?.[key]}
<span
class={twMerge(
'inline-flex items-center h-4 border px-1 rounded-[0.275rem] rounded-l-none border-l-0 gap-0.5',
'text-2xs'
)}
title={inputTransform[key].type === 'javascript' ? inputTransform[key].expr : 'Static'}
>
{#if inputTransform[key].type === 'javascript'}
<SquareFunction size={14} class="text-blue-500 -my-1 dark:text-blue-400" />
{:else if inputTransform[key].type === 'static'}
<DollarSign size={12} class="text-tertiary font-mono -my-1" />
{/if}
{#if testSteps?.isArgManuallySet(id, key)}
<button
onclick={() => {
testSteps?.evalArg(id, key, $flowStateStore, flowStore?.val, previewArgs?.val)
}}
title="Re-evaluate input"
class="-my-1 ml-0.5 hover:text-primary dark:hover:text-primary dark:text-gray-500 text-gray-300"
>
<RefreshCw size={12} />
</button>
{/if}
</span>
{/if}
{/snippet}
{#snippet editKey(key: string)}
<button
onclick={() => onEditInput?.(id, key)}
class="h-4 w-fit items-center text-gray-300 dark:text-gray-500 hover:text-primary dark:hover:text-primary px-1 rounded-[0.275rem] align-baseline"
>
<Pencil size={12} class="-my-1 inline-flex items-center" />
</button>
{/snippet}
@@ -1,10 +1,12 @@
<script lang="ts">
import { ChevronDown } from 'lucide-svelte'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import { twMerge } from 'tailwind-merge'
import { getContext } 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'
interface Props {
selected?: boolean
@@ -13,6 +15,11 @@
variant?: 'default' | 'virtual'
historyOpen?: boolean
children?: import('svelte').Snippet<[any]>
inputTransform?: Record<string, any> | undefined
id: string
bottomBarOpen?: boolean
loopStatus?: { type: 'inside' | 'self'; flow: 'forloopflow' | 'whileloopflow' } | undefined
onEditInput?: (moduleId: string, key: string) => void
}
let {
@@ -21,7 +28,12 @@
isConnectingCandidate = false,
variant = 'default',
historyOpen = false,
children
children,
inputTransform,
id,
bottomBarOpen = $bindable(false),
loopStatus,
onEditInput
}: Props = $props()
const context = getContext<PropPickerContext>('PropPickerContext')
@@ -29,6 +41,11 @@
const MIN_WIDTH = 375
const MIN_HEIGHT = 375
let outputOpen = $state(false)
let inputOpen = $state(false)
const zoom = $derived.by(useSvelteFlow().getZoom)
let showConnecting = $derived(
isConnectingCandidate && $flowPropPickerConfig?.insertionMode === 'connect'
)
@@ -40,98 +57,178 @@
}
}
let inputPopover: Popover | undefined = $state(undefined)
let popover: Popover | undefined = $state(undefined)
const virtualItemClasses = {
bar: 'dark:hover:bg-[#525d6f] dark:bg-[#414958] bg-[#d7dfea] hover:bg-slate-300',
handle:
'dark:group-hover:bg-[#525d6f] dark:hover:bg-[#525d6f] dark:bg-[#414958] bg-[#d7dfea] hover:bg-slate-300 group-hover:bg-slate-300'
bar: 'dark:hover:bg-[#525d6f] dark:bg-[#414958] bg-[#d7dfea] hover:bg-slate-300'
}
const defaultClasses = {
bar: 'bg-surface-disabled hover:bg-surface-hover dark:bg-[#454e5f] dark:hover:bg-[#576278]',
handle:
'group-hover:bg-surface-hover hover:bg-surface-hover bg-surface-disabled dark:bg-[#454e5f] dark:hover:bg-[#576278] dark:group-hover:bg-[#576278]'
bar: 'bg-surface-disabled hover:bg-surface-hover dark:bg-[#454e5f] dark:hover:bg-[#576278]'
}
export function toggleOpen() {
if (popover?.isOpened()) {
export function toggleOpen(forceOpen: boolean = false) {
if (popover?.isOpened() && !forceOpen) {
popover?.close()
} else {
popover?.open()
}
}
$effect(() => {
bottomBarOpen = inputOpen || outputOpen || selected || hover || showConnecting
})
const showInput = $derived(
variant === 'default' && !showConnecting && loopStatus?.type !== 'self'
)
function updatePositioning(historyOpen: boolean, zoom: number) {
inputPopover?.updatePositioning({
placement: 'bottom',
gutter: 0,
offset: { mainAxis: 3, crossAxis: 69 * zoom },
overflowPadding: historyOpen ? 250 : 8
})
popover?.updatePositioning({
placement: 'bottom',
gutter: 0,
offset: { mainAxis: 3, crossAxis: showInput ? -69 * zoom : 0 },
overflowPadding: historyOpen ? 250 : 8
})
}
$effect(() => {
updatePositioning(historyOpen, zoom)
})
</script>
<Popover
floatingConfig={{
placement: 'bottom',
overflowPadding: historyOpen ? 250 : 8
}}
usePointerDownOutside
closeOnOutsideClick={false}
on:click={(e) => {
<div
class="relative h-1 w-[275px]"
onpointerdown={(e) => {
e.preventDefault()
e.stopPropagation()
}}
bind:this={popover}
allowFullScreen
contentClasses="overflow-hidden resize rounded-md"
contentStyle={`width: calc(${MIN_WIDTH}px); min-width: calc(${MIN_WIDTH}px); height: calc(${MIN_HEIGHT}px); min-height: calc(${MIN_HEIGHT}px);`}
extraProps={{ 'data-prop-picker': true }}
closeOnOtherPopoverOpen
class="outline-none"
>
{#snippet trigger({ isOpen })}
<div
class={twMerge(
'bg-slate-200',
`w-[275px] h-[4px] flex flex-row items-center justify-center cursor-pointer`,
variant === 'virtual' ? virtualItemClasses.bar : defaultClasses.bar,
'shadow-[inset_0_1px_5px_0_rgba(0,0,0,0.05)] rounded-b-sm',
'group'
)}
onpointerdown={(e) => {
e.preventDefault()
e.stopPropagation()
}}
data-prop-picker
title={`${isOpen ? 'Close' : 'Open'} step output`}
>
<div class="absolute bottom-0 left-1/2 -translate-x-1/2 w-10 h-[14px]">
<AnimatedButton
animate={showConnecting}
wrapperClasses="relative w-full h-full center-center"
baseRadius="6px"
marginWidth="1px"
<!-- Invisible hover area to maintain consistent height -->
<div class="absolute w-full h-[20px]"></div>
<div
class={twMerge(
'bg-slate-200 absolute w-full',
variant === 'virtual'
? `${virtualItemClasses.bar} ${bottomBarOpen ? 'bg-slate-300 dark:bg-[#525d6f]' : ''}`
: `${defaultClasses.bar} ${bottomBarOpen ? 'bg-surface-hover dark:bg-[#576278]' : ''}`,
'shadow-[inset_0_1px_5px_0_rgba(0,0,0,0.05)] rounded-b-sm',
'group transition-all duration-100',
'flex flex-row items-center justify-center',
'h-1 hover:h-[20px]',
bottomBarOpen && 'h-[20px]'
)}
data-prop-picker
>
<div class="flex flex-row items-center justify-center w-full h-full">
{#if showInput}
<Popover
floatingConfig={{
placement: 'bottom',
gutter: 0,
offset: { mainAxis: 3, crossAxis: 69 },
overflowPadding: historyOpen ? 250 : 8
}}
usePointerDownOutside
closeOnOutsideClick={false}
on:click={(e) => {
e.preventDefault()
e.stopPropagation()
}}
allowFullScreen
contentClasses="overflow-hidden resize"
contentStyle={`width: calc(${MIN_WIDTH}px); min-width: calc(${MIN_WIDTH}px); height: calc(${MIN_HEIGHT}px); min-height: calc(${MIN_HEIGHT}px); `}
extraProps={{ 'data-prop-picker': true }}
closeOnOtherPopoverOpen
disableFocusTrap
class="flex-1 h-full"
bind:isOpen={inputOpen}
bind:this={inputPopover}
>
<div
class={twMerge(
'w-full h-full rounded-t-md shadow-[inset_0_1px_5px_0_rgba(0,0,0,0.05)]',
`hidden group-hover:center-center`,
variant === 'virtual' ? virtualItemClasses.handle : defaultClasses.handle,
isOpen || selected || hover || showConnecting ? 'center-center' : 'hidden',
showConnecting ? 'text-blue-500 bg-surface rounded-b-md' : 'text-secondary'
{#snippet trigger({ isOpen })}
<button
class={twMerge(
'h-full center-center transition-opacity duration-150 w-full',
bottomBarOpen ? 'opacity-100' : 'opacity-0',
'text-2xs font-normal w-full h-full border-t-2 border-transparent',
inputOpen ? 'border-primary' : 'hover:border-primary/20'
)}
>
In
</button>
{/snippet}
{#snippet content()}
<InputPickerInner {inputTransform} {id} {onEditInput} />
{/snippet}
</Popover>
{/if}
<Popover
floatingConfig={{
placement: 'bottom',
gutter: 0,
offset: { mainAxis: 3, crossAxis: showInput ? -69 : 0 },
overflowPadding: historyOpen ? 250 : 8
}}
usePointerDownOutside
closeOnOutsideClick={false}
on:click={(e) => {
e.preventDefault()
e.stopPropagation()
}}
bind:this={popover}
allowFullScreen
contentClasses="overflow-hidden resize"
contentStyle={`width: calc(${MIN_WIDTH}px); min-width: calc(${MIN_WIDTH}px); height: calc(${MIN_HEIGHT}px); min-height: calc(${MIN_HEIGHT}px); `}
extraProps={{ 'data-prop-picker': true }}
closeOnOtherPopoverOpen
class="flex-1 h-full"
bind:isOpen={outputOpen}
>
{#snippet trigger({ isOpen })}
<AnimatedButton
animate={showConnecting}
wrapperClasses={twMerge(
'h-full center-center transition-opacity duration-150 w-full',
bottomBarOpen ? 'opacity-100' : 'opacity-0'
)}
baseRadius="2px"
marginWidth="1px"
>
<ChevronDown
size={12}
class="h-fit transition-transform duration-100"
style={`transform: rotate(${isOpen ? '180deg' : '0deg'})`}
/>
</div>
</AnimatedButton>
</div>
<button
class={twMerge(
'text-2xs font-normal w-full h-full border-t-2 border-transparent',
outputOpen ? 'border-primary' : 'hover:border-primary/20',
showConnecting ? 'bg-surface-hover rounded-sm border-0' : ''
)}
>
{#if showInput}
Out
{:else if showConnecting}
<Plug size={12} class="w-full text-blue-500" />
{:else}
<ChevronDown size={12} class="w-full" />
{/if}
</button>
</AnimatedButton>
{/snippet}
{#snippet content()}
{@render children?.({
allowCopy: !$flowPropPickerConfig,
isConnecting: showConnecting,
selectConnection
})}
{/snippet}
</Popover>
</div>
{/snippet}
{#snippet content()}
{@render children?.({
allowCopy: !$flowPropPickerConfig,
isConnecting: showConnecting,
selectConnection
})}
{/snippet}
</Popover>
</div>
</div>
<style>
@keyframes moveGradient {
@@ -16,7 +16,7 @@
<script lang="ts">
import Button from '$lib/components/common/button/Button.svelte'
import { Pin, History, Pen, Check, X, Loader2 } from 'lucide-svelte'
import { Pin, History, Pen, Check, X, Loader2, Pencil } from 'lucide-svelte'
import ObjectViewer from '$lib/components/propertyPicker/ObjectViewer.svelte'
import StepHistory from './StepHistory.svelte'
import { Popover } from '$lib/components/meltComponents'
@@ -62,6 +62,8 @@
copilot_fix?: import('svelte').Snippet
onSelect?: (key: string) => void
onUpdateMock?: (mock: { enabled: boolean; return_value?: unknown }) => void
onEditInput?: (moduleId: string, key: string) => void
selectionId?: string
}
let {
@@ -91,7 +93,9 @@
clazz,
copilot_fix,
onSelect,
onUpdateMock
onUpdateMock,
onEditInput,
selectionId
}: Props = $props()
type SelectedJob =
@@ -127,6 +131,8 @@
selectedJob = job
} else if (lastJob && 'result' in lastJob) {
selectedJob = lastJob
} else {
selectedJob = undefined
}
}
@@ -136,8 +142,9 @@
}
selectJob(lastJob)
if (lastJob.preview) {
if (lastJob.preview && mock?.enabled) {
preview = 'job'
lastJob.preview = false
}
})
@@ -589,6 +596,7 @@
onSelect?.(e.detail)
}}
{allowCopy}
{editKey}
/>
{:else if jsonView}
{#await import('$lib/components/JsonEditor.svelte')}
@@ -694,6 +702,15 @@
</div>
</div>
{#snippet editKey(key: string)}
<button
onclick={() => onEditInput?.(selectionId ?? '', key)}
class="h-4 w-fit items-center text-gray-300 dark:text-gray-500 hover:text-primary dark:hover:text-primary px-1 rounded-[0.275rem] align-baseline"
>
<Pencil size={12} class="-my-1 inline-flex items-center" />
</button>
{/snippet}
<style>
.dbl-click-editable {
cursor: text;
@@ -0,0 +1,183 @@
import type { FlowModule, OpenFlow } from '$lib/gen'
import type { FlowState } from './flowState'
import {
dfs,
getPreviousModule,
getStepPropPicker,
type PickableProperties
} from './previousResults'
import { evalValue, type ModuleArgs } from './utils'
export class TestSteps {
#stepsEvaluated = $state<Record<string, ModuleArgs>>({})
#steps = $state<Record<string, { value: any }>>({})
constructor() {}
setStepArgsManually(moduleId: string, args: Record<string, any>) {
if (!this.#steps[moduleId]) {
this.#steps[moduleId] = { value: {} }
}
this.#steps[moduleId].value = args
}
getStepArgs(moduleId: string): ModuleArgs | undefined {
return this.#steps[moduleId]
}
setStepArgs(moduleId: string, args: Record<string, any>) {
if (!this.#steps[moduleId]) {
this.#steps[moduleId] = { value: {} }
}
this.#steps[moduleId].value = args
}
getStepArg(moduleId: string, argName: string): any | undefined {
return this.#steps[moduleId]?.[argName]
}
setEvaluatedStepArg(moduleId: string, argName: string, value: any) {
if (!this.#steps[moduleId]) {
this.#steps[moduleId] = { value: {} }
}
if (!this.#stepsEvaluated[moduleId]) {
this.#stepsEvaluated[moduleId] = { value: {} }
}
this.#steps[moduleId].value[argName] = $state.snapshot(value)
this.#stepsEvaluated[moduleId].value[argName] = $state.snapshot(value)
}
isArgManuallySet(moduleId: string, argName: string): boolean {
return (
JSON.stringify(this.#steps[moduleId]?.value?.[argName]) !==
JSON.stringify(this.#stepsEvaluated[moduleId]?.value?.[argName])
)
}
getManuallyEditedArgs(moduleId: string): string[] {
const manuallyEditedArgs: string[] = []
const moduleArgs = this.#steps[moduleId]?.value ?? {}
Object.keys(moduleArgs).forEach((argName) => {
if (this.isArgManuallySet(moduleId, argName)) {
manuallyEditedArgs.push(argName)
}
})
return manuallyEditedArgs
}
/*
Evaluate the arg value from the flow state and replace the test value.
*/
evalArg(
moduleId: string,
argName: string,
flowState: FlowState | undefined,
flow: OpenFlow | undefined,
previewArgs: Record<string, any> | undefined
) {
if (!flowState || !flow) {
return
}
const modules = dfs(moduleId, flow, true)
const previousModule = getPreviousModule(moduleId, flow)
if (modules.length < 1) {
return
}
let parentModule: FlowModule | undefined = undefined
if (modules.length > 1) {
parentModule = modules[modules.length - 1]
}
const stepPropPicker = getStepPropPicker(
flowState,
parentModule,
previousModule,
moduleId,
flow,
previewArgs,
false
)
const pickableProperties = stepPropPicker.pickableProperties
const argSnapshot = $state.snapshot(evalValue(argName, modules[0], pickableProperties, false))
this.#stepsEvaluated[moduleId].value[argName] = argSnapshot
this.#steps[moduleId].value[argName] = structuredClone(argSnapshot)
}
initializeFromSchema(
mod: FlowModule,
schema: { properties?: Record<string, any> },
pickableProperties: PickableProperties | undefined
) {
const args = Object.fromEntries(
Object.keys(schema.properties ?? {}).map((k) => [
k,
evalValue(k, mod, pickableProperties, false)
])
)
const manuallyEditedArgs = this.getManuallyEditedArgs(mod.id)
if (!this.#steps[mod.id]) {
this.#steps[mod.id] = { value: {} }
}
if (!this.#stepsEvaluated[mod.id]) {
this.#stepsEvaluated[mod.id] = { value: {} }
}
this.#stepsEvaluated[mod.id].value = $state.snapshot(args)
// Preserve manually edited args
const argsSnapshot = $state.snapshot(args)
Object.keys(argsSnapshot).forEach((key) => {
if (manuallyEditedArgs.includes(key)) {
argsSnapshot[key] = this.#steps[mod.id]?.value?.[key]
}
})
this.#steps[mod.id].value = argsSnapshot
}
updateStepArgs(
id: string,
flowState: FlowState | undefined,
flow: OpenFlow | undefined,
previewArgs: Record<string, any> | undefined
) {
if (!flowState || !flow) {
return
}
const modules = dfs(id, flow, true)
const previousModule = getPreviousModule(id, flow)
if (modules.length < 1) {
return
}
let parentModule: FlowModule | undefined = undefined
if (modules.length > 1) {
parentModule = modules[modules.length - 1]
}
const stepPropPicker = getStepPropPicker(
flowState,
parentModule,
previousModule,
id,
flow,
previewArgs,
false
)
const pickableProperties = stepPropPicker.pickableProperties
this.initializeFromSchema(modules[0], flowState[id]?.schema ?? {}, pickableProperties)
}
removeExtraKey(moduleId: string, keys: string[]) {
if (!this.#stepsEvaluated[moduleId]) {
return
}
const nargs = {}
Object.keys(this.#stepsEvaluated[moduleId]?.value ?? {}).forEach((key) => {
if (keys.includes(key)) {
nargs[key] = this.#stepsEvaluated[moduleId]?.value?.[key]
}
})
this.#stepsEvaluated[moduleId].value = nargs
}
}
+2 -1
View File
@@ -7,6 +7,7 @@ import type { FlowBuilderWhitelabelCustomUi } from '../custom_ui'
import type Editor from '../Editor.svelte'
import type SimpleEditor from '../SimpleEditor.svelte'
import type { StateStore } from '$lib/utils'
import type { TestSteps } from './testSteps.svelte'
export type FlowInput = Record<
string,
@@ -69,7 +70,7 @@ export type FlowEditorContext = {
flowStore: StateStore<ExtendedOpenFlow>
flowInputEditorState: Writable<FlowInputEditorState>
flowStateStore: Writable<FlowState>
testStepStore: Writable<Record<string, any>>
testSteps: TestSteps
saveDraft: () => void
initialPathStore: Writable<string>
fakeInitialPath: string
+18 -21
View File
@@ -29,6 +29,8 @@ return ${eval_string}
}`
}
export type ModuleArgs = { value: Record<string, any> }
function make_context_evaluator(eval_string, context): (context) => any {
let template = create_context_function_template(eval_string, context)
let functor = Function(template)
@@ -38,34 +40,29 @@ function make_context_evaluator(eval_string, context): (context) => any {
export function evalValue(
k: string,
mod: FlowModule,
testStepStore: Record<string, any>,
pickableProperties: PickableProperties | undefined,
showError: boolean
) {
): any {
let inputTransforms = (mod.value['input_transforms'] ?? {}) as Record<string, InputTransform>
let v = testStepStore[mod.id]?.[k]
let v: any
let t = inputTransforms?.[k]
if (!v) {
if (t.type == 'static') {
v = t.value
} else {
try {
let context = {
flow_input: pickableProperties?.flow_input,
results: pickableProperties?.priorIds
}
v = make_context_evaluator(t.expr, context)(context)
} catch (e) {
if (showError) {
sendUserToast(`Error evaluating ${k}: ${e.message}`, true)
}
v = undefined
if (t.type == 'static') {
v = t.value
} else {
try {
let context = {
flow_input: pickableProperties?.flow_input,
results: pickableProperties?.priorIds
}
v = make_context_evaluator(t.expr, context)(context)
} catch (e) {
if (showError) {
sendUserToast(`Error evaluating ${k}: ${e.message}`, true)
}
v = undefined
}
}
if (v == NEVER_TESTED_THIS_FAR) {
return undefined
}
return v
}
@@ -107,7 +107,9 @@
onChangeId?: (detail: { id: string; newId: string; deps: Record<string, string[]> }) => void
onMove?: (id: string) => void
onUpdateMock?: (detail: { mock: FlowModule['mock']; id: string }) => void
onTestUpTo?: ((id: string) => void) | undefined
onSelectedIteration?: onSelectedIteration
onEditInput?: (moduleId: string, key: string) => void
}
let {
@@ -145,7 +147,9 @@
workspace = $workspaceStore ?? 'NO_WORKSPACE',
editMode = false,
allowSimplifiedPoll = true,
expandedSubflows = $bindable({})
expandedSubflows = $bindable({}),
onTestUpTo = undefined,
onEditInput = undefined
}: Props = $props()
setContext<{
@@ -301,6 +305,12 @@
},
updateMock: (detail) => {
onUpdateMock?.(detail)
},
testUpTo: (id: string) => {
onTestUpTo?.(id)
},
editInput: (moduleId: string, key: string) => {
onEditInput?.(moduleId, key)
}
}
@@ -52,6 +52,8 @@ export type GraphEventHandlers = {
expandSubflow: (id: string, path: string) => void
minimizeSubflow: (id: string) => void
updateMock: (detail: { mock: FlowModule['mock']; id: string }) => void
testUpTo: (id: string) => void
editInput: (moduleId: string, key: string) => void
}
export type SimplifiableFlow = { simplifiedFlow: boolean }
@@ -57,7 +57,7 @@
)
</script>
<EdgeLabel x={sourceX} y={sourceY + 22} class="base-edge" style="">
<EdgeLabel x={sourceX} y={sourceY + 28} class="base-edge" style="">
{#if data?.insertable && !$useDataflow && !data?.moving}
<div
class={twMerge('edgeButtonContainer nodrag nopan top-0')}
@@ -76,6 +76,7 @@
cache={data.cache}
earlyStop={data.earlyStop}
editMode={data.editMode}
onEditInput={data.eventHandlers.editInput}
/>
{/snippet}
</NodeWrapper>
@@ -89,9 +89,11 @@
onSelectedIteration={(e) => {
data.eventHandlers.selectedIteration(e)
}}
onTestUpTo={data.eventHandlers.testUpTo}
onUpdateMock={(detail) => {
data.eventHandlers.updateMock(detail)
}}
onEditInput={data.eventHandlers.editInput}
/>
<div class="absolute -bottom-10 left-1/2 transform -translate-x-1/2 z-10">
+1 -1
View File
@@ -2,7 +2,7 @@ import type { FlowStatusModule } from '$lib/gen'
export const NODE = {
width: 275,
height: 38,
height: 34,
gap: {
horizontal: 40,
vertical: 50
@@ -16,6 +16,7 @@
import { createEventDispatcher } from 'svelte'
import { Button } from '$lib/components/common'
import DocLink from '$lib/components/apps/editor/settingsPanel/DocLink.svelte'
import type { FloatingConfig } from '@melt-ui/svelte/internal/actions/floating'
export let closeButton: boolean = false
export let displayArrow: boolean = false
@@ -33,6 +34,7 @@
export let extraProps: Record<string, any> = {}
export let disabled: boolean = false
export let documentationLink: string | undefined = undefined
export let disableFocusTrap: boolean = false
let fullScreen = false
const dispatch = createEventDispatcher()
@@ -45,6 +47,7 @@
} = createPopover({
forceVisible: true,
portal,
disableFocusTrap,
onOpenChange: ({ curr, next }) => {
if (curr != next) {
dispatch('openChange', next)
@@ -94,6 +97,12 @@
return isOpen
}
export function updatePositioning(pos: FloatingConfig) {
if (positioning) {
$positioning = pos
}
}
async function getMenuElements(): Promise<HTMLElement[]> {
return Array.from(document.querySelectorAll('[data-popover]')) as HTMLElement[]
}
@@ -3,7 +3,7 @@
import { copyToClipboard, truncate } from '$lib/utils'
import { createEventDispatcher, untrack } from 'svelte'
import { createEventDispatcher, untrack, type Snippet } from 'svelte'
import { computeKey, keepByKeyOrValue } from './utils'
import { NEVER_TESTED_THIS_FAR } from '../flows/models'
import Portal from '$lib/components/Portal.svelte'
@@ -13,6 +13,7 @@
import { workspaceStore } from '$lib/stores'
import AnimatedButton from '$lib/components/common/button/AnimatedButton.svelte'
import Popover from '../Popover.svelte'
import { twMerge } from 'tailwind-merge'
interface Props {
json: any
@@ -27,6 +28,8 @@
prefix?: string
expandedEvenOnLevel0?: string | undefined
connecting?: boolean
metaData?: Snippet<[any]>
editKey?: Snippet<[any]>
}
let {
@@ -41,7 +44,9 @@
collapseLevel = undefined,
prefix = '',
expandedEvenOnLevel0 = undefined,
connecting = false
connecting = false,
metaData,
editKey
}: Props = $props()
let jsonFiltered = $state(json)
@@ -248,11 +253,16 @@
color="light"
variant="border"
wrapperClasses="p-0 whitespace-nowrap w-fit"
btnClasses="font-mono h-4 py-1 text-2xs font-thin px-1 rounded-[0.275rem]"
btnClasses={twMerge(
'font-mono h-4 py-1 text-2xs',
'font-thin px-1 rounded-[0.275rem]',
metaData ? 'rounded-r-none border-r-0.5' : ''
)}
title={computeFullKey(key, rawKey)}
>
<span class={pureViewer ? 'cursor-auto' : ''}>{!isArray ? key : index} </span>
<span class={pureViewer ? 'cursor-auto' : ''}>{!isArray ? key : index}</span>
</Button>
{@render metaData?.(key)}
</AnimatedButton>
<span class="text-2xs -ml-0.5 text-tertiary">:</span>
@@ -273,6 +283,7 @@
{:else}
{@render renderScalar(key, jsonFiltered[key])}
{/if}
{@render editKey?.(key)}
</li>
{/each}
{#if keys.length > keyLimit}
+4 -3
View File
@@ -24,6 +24,7 @@
import type { FlowPropPickerConfig, PropPickerContext } from '$lib/components/prop_picker'
import type { PickableProperties } from '$lib/components/flows/previousResults'
import { Triggers } from '$lib/components/triggers/triggers.svelte'
import { TestSteps } from '$lib/components/flows/testSteps.svelte'
let token = $page.url.searchParams.get('wm_token') ?? undefined
let workspace = $page.url.searchParams.get('workspace') ?? undefined
@@ -73,7 +74,7 @@
const moving = writable<{ id: string } | undefined>(undefined)
const history = initHistory(flowStore.val)
const testStepStore = writable<Record<string, any>>({})
const testSteps = new TestSteps()
const selectedIdStore = writable('settings-metadata')
const triggersCount = writable<TriggersCount | undefined>(undefined)
setContext<TriggerContext>('TriggerContext', {
@@ -92,7 +93,7 @@
pathStore: writable(''),
flowStateStore,
flowStore,
testStepStore,
testSteps,
saveDraft: () => {},
initialPathStore: writable(''),
fakeInitialPath: '',
@@ -288,7 +289,7 @@
noEditor
on:applyArgs={(ev) => {
if (ev.detail.kind === 'preprocessor') {
$testStepStore['preprocessor'] = ev.detail.args ?? {}
testSteps.setStepArgs('preprocessor', ev.detail.args ?? {})
$selectedIdStore = 'preprocessor'
} else {
previewArgsStore.val = ev.detail.args ?? {}