Files
windmill/frontend/src/lib/components/flows/propPicker/OutputPickerInner.svelte
T
Ruben Fiszel f481ea4059 fix(frontend): fix output of resultnode + svelte5 nits (#8424)
* fix(frontend): remove banned $bindable('') pattern from ClearableInput

Switching format types in the flow input editor caused a
props_invalid_value error because ClearableInput used
value = $bindable(''), which conflicts with undefined bindings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(frontend): restore flow result display in result node

The fix in #8390 changed updateLastJob() to only use testJob when
actively running/streaming, preferring flowStateStore for completed
results. But the result node has moduleId='' and no flowStateStore
entry, so the early return made it always show the empty state.

Add !moduleId to the testJob condition so the result node (which has
no flowStateStore entry) still uses testJob as its only data source.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 08:15:04 +00:00

792 lines
21 KiB
Svelte

<script module>
function useResizeObserver(node, callback) {
const observer = new ResizeObserver(() => {
callback()
})
observer.observe(node)
return {
destroy() {
observer.disconnect()
}
}
}
</script>
<script lang="ts">
import Button from '$lib/components/common/button/Button.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'
import { getContext, untrack } from 'svelte'
import { Tooltip } from '$lib/components/meltComponents'
import type { Job } from '$lib/gen'
import DisplayResult from '$lib/components/DisplayResult.svelte'
import OutputBadge from './OutputBadge.svelte'
import { twMerge } from 'tailwind-merge'
import DisplayResultControlBar from '$lib/components/DisplayResultControlBar.svelte'
import { base } from '$lib/base'
import { fade } from 'svelte/transition'
import type { FlowEditorContext, OutputViewerJob } from '../types'
interface Props {
prefix?: string
allowCopy?: boolean
connectingData?: any | undefined
mock?:
| {
enabled?: boolean
return_value?: unknown
}
| undefined
moduleId?: string
fullResult?: boolean
closeOnOutsideClick?: boolean
getLogs?: boolean
isLoading?: boolean
hideHeaderBar?: boolean
simpleViewer?: any | undefined
path?: string
loopStatus?: { type: 'inside' | 'self'; flow: 'forloopflow' | 'whileloopflow' } | undefined
customHeight?: number | undefined
rightMargin?: boolean
disableMock?: boolean
disableHistory?: boolean
testJob?: Job & { result_stream?: string }
derivedHistoryOpen?: boolean // derived from historyOpen
historyOffset?: any
clazz?: string
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
initial?: boolean
onResetInitial?: () => void
customEmptyJobMessage?: string
}
let {
testJob,
prefix = '',
allowCopy = false,
connectingData = undefined,
mock = $bindable({ enabled: false }),
moduleId = '',
fullResult = false,
closeOnOutsideClick = false,
getLogs = false,
isLoading = $bindable(false),
hideHeaderBar = false,
simpleViewer = undefined,
path = '',
loopStatus = undefined,
customHeight = undefined,
rightMargin = false,
disableMock = false,
disableHistory = false,
historyOffset = { mainAxis: 8, crossAxis: -4.5 },
clazz,
copilot_fix,
onSelect,
onUpdateMock,
onEditInput,
selectionId,
initial,
customEmptyJobMessage
}: Props = $props()
let jsonView = $state(false)
let clientHeight: number = $state(0)
let tmpMock: { enabled: boolean; return_value?: unknown } | undefined = $state(undefined)
let error = $state('')
let stepHistoryPopover: Popover | undefined = $state(undefined)
let historyOpen = $state(false)
let contentEl: HTMLDivElement | undefined = $state(undefined)
let hasOverflow = $state(false)
let preview: 'mock' | 'job' | undefined = $state(undefined)
let selectedJob: OutputViewerJob = $state(undefined)
function checkOverflow() {
if (contentEl) {
hasOverflow = contentEl.scrollHeight > contentEl.clientHeight
}
}
function selectJob(nJob: OutputViewerJob | undefined) {
if (nJob && (nJob.result_stream || nJob.type == 'CompletedJob')) {
selectedJob = nJob
} else if (lastJob && (lastJob.result_stream || lastJob.type == 'CompletedJob')) {
selectedJob = lastJob
} else {
selectedJob = undefined
}
}
$effect(() => {
if (!lastJob || !(lastJob.result_stream || lastJob.type == 'CompletedJob')) {
return
}
selectJob(lastJob)
})
function togglePreview(nPrev: 'mock' | 'job' | undefined) {
if (!nPrev && preview === 'job') {
// Reset the job
selectJob(undefined)
}
preview = nPrev
}
let dblClickDisabled = $state(false)
let hoveringResult = $state(false)
let debouncedCanEditWithDblClick = $state(false)
let debounceTimeout: ReturnType<typeof setTimeout> | null = null
let canEditWithDblClick = $state(false)
let displayResultJob: DisplayResult | undefined = $state(undefined)
let displayResultMock: DisplayResult | undefined = $state(undefined)
let toolbarLocationJob: 'self' | 'external' | undefined = $state(undefined)
let toolbarLocationMock: 'self' | 'external' | undefined = $state(undefined)
const flowEditorContext = getContext<FlowEditorContext | undefined>('FlowEditorContext')
const flowStateStore = flowEditorContext?.flowStateStore
function updateCanEditWithDblClick(newValue: boolean) {
canEditWithDblClick = newValue
if (debounceTimeout) {
clearTimeout(debounceTimeout)
}
debounceTimeout = setTimeout(() => {
debouncedCanEditWithDblClick = newValue
}, 200)
}
function togglePin() {
if (mock?.enabled && !preview) {
// Unpin
onUpdateMock?.({
...mock,
enabled: false
})
} else if (preview === 'mock') {
// Restore the pin
onUpdateMock?.({
...mock,
enabled: true
})
} else if (selectedJob && 'result' in selectedJob) {
// Pin the job
let mockValue: any = structuredClone($state.snapshot(selectedJob.result))
if (selectedJob.result === 'never tested this far') {
mockValue = { example: 'value' }
}
const newMock = {
enabled: true,
return_value: mockValue
}
onUpdateMock?.(newMock)
} else {
// Fallback to mock
onUpdateMock?.({
enabled: true,
return_value: { example: 'value' }
})
}
if (preview) {
// Reset the preview
if (historyOpen) {
stepHistoryPopover?.close()
}
togglePreview(undefined)
}
}
function updateLastJob() {
// Prefer testJob when actively running/streaming (individual step test in progress)
// or when there's no moduleId (result node — no flowStateStore entry exists)
if (testJob && (testJob.result_stream || testJob.type === 'QueuedJob' || !moduleId)) {
return testJob
}
if (
!flowStateStore ||
!moduleId ||
flowStateStore.val[moduleId]?.previewResult === 'never tested this far'
) {
return
}
// Use flowStateStore as source of truth — it's updated by both individual step tests
// (ModuleTest.jobDone) and flow tests (FlowStatusViewerInner.onJobsLoadedInner)
return {
id: flowStateStore.val[moduleId]?.previewJobId ?? '',
result: flowStateStore.val[moduleId]?.previewResult,
type: 'CompletedJob' as const,
success: flowStateStore.val[moduleId]?.previewSuccess ?? undefined
} as Job & { result_stream?: string } & { preview?: boolean }
}
const lastJob = $derived.by(updateLastJob)
export function setJobPreview() {
if (mock?.enabled) {
preview = 'job'
}
}
const mockUpdateStatus = $derived(
preview === 'mock' && !mock?.enabled
? 'restore'
: preview === 'job' && mock?.enabled && selectedJob?.['type'] === 'CompletedJob'
? 'override'
: undefined
)
$effect(() => {
if (displayResultJob && typeof displayResultJob.getToolbarLocation === 'function') {
toolbarLocationJob = displayResultJob.getToolbarLocation()
}
})
$effect(() => {
if (displayResultMock && typeof displayResultMock.getToolbarLocation === 'function') {
toolbarLocationMock = displayResultMock.getToolbarLocation()
}
})
$effect(() => {
const newValue =
!!mock?.enabled &&
!connectingData &&
!dblClickDisabled &&
hoveringResult &&
!jsonView &&
!preview
if (newValue != canEditWithDblClick) {
canEditWithDblClick = newValue
untrack(() => updateCanEditWithDblClick(newValue))
}
})
let popoverHeight = $derived(customHeight ?? (clientHeight > 0 ? clientHeight : 0))
// If a test job is running, we override the preview to show the test job
const executingTestJob = $derived(testJob && testJob.type === 'QueuedJob')
const isLoadingAndNotMock = $derived(
isLoading && lastJob?.result_stream === undefined && (!mock?.enabled || executingTestJob)
)
const copilot_fix_render = $derived(copilot_fix)
export function getPreview() {
return preview
}
async function selectJobFromHistory({
detail
}: {
detail: 'mock' | (Job & { getFullJob: () => Promise<Job> })
}) {
if (!detail) {
togglePreview(undefined)
return
}
if (detail === 'mock') {
if (mock?.enabled) {
togglePreview(undefined)
return
}
togglePreview('mock')
return
}
if (detail.id === lastJob?.id && !mock?.enabled) {
togglePreview(undefined)
return
}
isLoading = true
try {
const fullJob = await detail.getFullJob()
if (fullJob) {
selectJob(fullJob)
togglePreview('job')
}
} finally {
isLoading = false
}
}
export function getHistoryOpen() {
return historyOpen
}
export function getSelectedJob() {
return selectedJob
}
const noHistory = $derived(
loopStatus ? (loopStatus.type === 'self' ? 'isLoop' : 'isInsideLoop') : undefined
)
</script>
<div
class={twMerge('w-full h-full flex flex-col', clazz)}
bind:clientHeight
style={canEditWithDblClick ? 'cursor: text;' : ''}
>
<!-- Top toolbar -->
<div
class={twMerge(
'text-xs px-1',
'border-none',
hideHeaderBar || connectingData ? 'hidden' : 'block',
hasOverflow ? 'shadow-sm' : ''
)}
>
<div
class={twMerge(
'flex flex-row items-center gap-2 justify-between min-h-[33.5px]',
rightMargin ? `mr-[30px]` : ''
)}
>
<div class="flex flex-row items-center gap-0.5">
<!-- History picker -->
{#if !disableHistory}
{@render historyPicker()}
{/if}
<!-- Badge-->
{#if executingTestJob}
<OutputBadge
job={testJob}
class={twMerge(
'min-w-16 min-h-[23px] text-secondary',
preview ? 'bg-surface shadow-sm' : ''
)}
/>
{:else if !isLoadingAndNotMock || mock?.enabled}
<div
class={twMerge(
'w-grow min-w-0 flex gap-1 items-center h-[27px] rounded-md group',
preview || selectedJob?.id !== lastJob?.id ? 'p-[2px] bg-surface-secondary' : ''
)}
>
{#if loopStatus?.type === 'self'}
<div
class={twMerge(
'min-w-16 rounded-md py-1 px-2 justify-center',
mockUpdateStatus ? 'bg-surface h-[23px]' : 'bg-surface-secondary h-[27px]'
)}
>
<span
class="text-xs text-secondary truncate"
title={loopStatus.flow === 'forloopflow'
? 'For loop result'
: 'While loop result'}
>
{loopStatus.flow === 'forloopflow' ? 'For loop result' : 'While loop result'}
</span>
</div>
{:else if preview === 'mock' || (mock?.enabled && !preview)}
<div
class={twMerge(
'min-w-16 text-secondary flex flex-row w-fit items-center gap-2 rounded-md bg-surface-secondary p-1 px-2',
mockUpdateStatus ? 'bg-surface h-[23px]' : 'bg-surface-secondary h-[27px]'
)}
>
<Pin size={12} />
<span class="text-xs text-secondary truncate"
>{mock?.enabled ? 'Pin' : 'Last pin'}</span
>
</div>
{:else if preview === 'job' || (selectedJob && !preview)}
<OutputBadge
job={selectedJob}
class={twMerge(
'min-w-16 text-secondary',
preview || selectedJob?.id !== lastJob?.id ? 'bg-surface shadow-sm h-[23px]' : ''
)}
/>
{/if}
{#if preview}
<button
class="px-1 shrink-0 text-secondary text-xs text-thin"
onclick={() => {
if (historyOpen) {
// closing history popover exits preview
stepHistoryPopover?.close()
}
togglePreview(undefined)
}}>Exit preview</button
>
{/if}
</div>
{/if}
<!-- Pin button -->
{#if !disableMock && !isLoadingAndNotMock}
<Tooltip disablePopup={mock?.enabled}>
<Button
color="light"
size="xs2"
variant="contained"
btnClasses={twMerge(
'bg-transparent h-[27px]',
mock?.enabled || mockUpdateStatus === 'override'
? 'text-white bg-blue-500 hover:text-primary hover:bg-blue-700 hover:text-gray-100'
: '',
jsonView ? 'pointer-events-none' : ''
)}
startIcon={{ icon: Pin }}
iconOnly={mockUpdateStatus !== 'override'}
on:click={() => togglePin()}
>
{#if mockUpdateStatus === 'override'}
Override pin
{/if}
</Button>
{#snippet text()}
Pin data
{/snippet}
</Tooltip>
{/if}
{#if jsonView}
<!-- Save button -->
<Button
size="xs2"
color="green"
variant="contained"
startIcon={{ icon: Check }}
btnClasses="h-[27px]"
on:click={() => {
if (!tmpMock) {
return
}
jsonView = false
mock = tmpMock
onUpdateMock?.({
enabled: tmpMock?.enabled ?? false,
return_value: tmpMock?.return_value
})
tmpMock = undefined
}}
disabled={!!error || !tmpMock}
/>
<!-- Cancel button -->
<Button
size="xs2"
color="red"
variant="contained"
startIcon={{ icon: X }}
btnClasses="h-[27px]"
on:click={() => {
jsonView = false
tmpMock = undefined
}}
/>
{:else if mock?.enabled && !preview && !executingTestJob}
<!-- Edit pin button -->
<Tooltip disablePopup={mock?.enabled}>
<Button
size="xs2"
color="light"
variant="contained"
startIcon={{ icon: Pen }}
on:click={() => {
stepHistoryPopover?.close()
jsonView = true
tmpMock = undefined
}}
disabled={!mock?.enabled || !!connectingData}
btnClasses={twMerge(
'transition-all duration-100 h-[27px]',
debouncedCanEditWithDblClick
? 'bg-blue-500/10 text-blue-800 dark:text-blue-200'
: ''
)}
/>
{#snippet text()}
{'Pin the output to allow editing'}
{/snippet}
</Tooltip>
{/if}
</div>
<div class="px-2">
{#if selectedJob && displayResultJob && toolbarLocationJob === 'external'}
<DisplayResultControlBar
workspaceId={selectedJob.workspace_id}
jobId={selectedJob.id}
{base}
result={selectedJob.result}
disableTooltips={false}
on:open-drawer={() => {
if (displayResultJob && typeof displayResultJob.openDrawer === 'function') {
displayResultJob.openDrawer()
}
}}
/>
{:else if mock?.enabled && displayResultMock && toolbarLocationMock === 'external'}
<DisplayResultControlBar
customUi={{ disableDownload: true }}
{base}
result={mock.return_value}
disableTooltips={false}
on:open-drawer={() => {
if (displayResultMock && typeof displayResultMock.openDrawer === 'function') {
displayResultMock.openDrawer()
}
}}
/>
{/if}
</div>
</div>
</div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_mouse_events_have_key_events -->
<div
class={twMerge(
'grow min-h-0 rounded-md w-full pl-2 py-1 pb-2 overflow-auto',
'transition-all duration-100 outline outline-2 outline-blue-500/0 outline-offset-[-2px]',
debouncedCanEditWithDblClick ? 'outline outline-2 outline-blue-500/30' : ''
)}
style="scrollbar-gutter: stable;"
>
<div
class={twMerge('h-full w-full rounded-md')}
bind:this={contentEl}
onscroll={checkOverflow}
use:useResizeObserver={checkOverflow}
onmouseover={(event) => {
if (
!event.target ||
!(event.target instanceof HTMLElement) ||
event.target.closest('[data-interactive]') ||
event.target.closest('button') ||
event.target.closest('input') ||
event.target.closest('textarea') ||
event.target.closest('select') ||
event.target.closest('option') ||
event.target.closest('label') ||
event.target.closest('a') ||
event.target.closest('svg')
) {
dblClickDisabled = true
} else {
dblClickDisabled = false
}
}}
ondblclick={() => {
if (canEditWithDblClick) {
stepHistoryPopover?.close()
jsonView = true
tmpMock = undefined
}
}}
onmouseenter={() => {
hoveringResult = true
}}
onmouseleave={() => {
hoveringResult = false
}}
>
{#if isLoadingAndNotMock}
<div class="flex flex-col items-center justify-center">
<Loader2 class="animate-spin" />
</div>
{:else if connectingData !== undefined || simpleViewer}
<!-- Display data for input connections -->
<ObjectViewer
json={moduleId
? {
[moduleId]: connectingData ?? simpleViewer
}
: (connectingData ?? simpleViewer)}
topBrackets={false}
pureViewer={false}
{prefix}
on:select={(e) => {
onSelect?.(e.detail)
}}
{allowCopy}
{editKey}
/>
{:else if jsonView}
<!-- Json editor for editing mock data -->
{#await import('$lib/components/JsonEditor.svelte')}
<Loader2 class="animate-spin" />
{:then Module}
<Module.default
bind:error
small
on:changeValue={({ detail }) => {
if (mock?.enabled) {
const newMock = {
enabled: true,
return_value: structuredClone($state.snapshot(detail))
}
tmpMock = newMock
}
}}
code={JSON.stringify(
mock?.enabled && mock.return_value ? mock.return_value : '',
null,
2
)}
class="h-full"
/>
{/await}
{:else if (mock?.enabled || preview == 'mock') && preview != 'job' && !executingTestJob}
<!-- Display data for mock data -->
{#if fullResult}
<div class="break-words relative h-full">
<DisplayResult
bind:this={displayResultMock}
workspaceId={undefined}
jobId={undefined}
result_stream={undefined}
result={mock?.return_value}
externalToolbarAvailable
fixTableSizingToParent
on:toolbar-location-changed={({ detail }) => {
toolbarLocationMock = detail
}}
/>
</div>
{:else}
<ObjectViewer
json={moduleId
? {
[moduleId]: mock?.return_value
}
: mock?.return_value}
topBrackets={false}
pureViewer={false}
/>
{/if}
{:else if selectedJob != undefined && (selectedJob.result_stream || selectedJob.type == 'CompletedJob')}
<!-- Display data for job data -->
{#if fullResult}
<div class="break-words relative h-full">
{#key selectedJob}
<DisplayResult
bind:this={displayResultJob}
workspaceId={selectedJob?.workspace_id}
jobId={selectedJob?.id}
result={selectedJob?.result}
result_stream={selectedJob?.result_stream}
externalToolbarAvailable
fixTableSizingToParent
on:toolbar-location-changed={({ detail }) => {
toolbarLocationJob = detail
}}
>
{#snippet copilot_fix()}
{@render copilot_fix_render?.()}
{/snippet}
</DisplayResult>
{/key}
</div>
{:else}
<ObjectViewer
json={moduleId
? {
[moduleId]: selectedJob.result
}
: selectedJob.result}
topBrackets={false}
pureViewer={false}
/>
{/if}
{:else if !lastJob}
<div class="flex flex-col items-center justify-center h-full">
<p class="text-xs text-secondary">
{customEmptyJobMessage ?? 'Test this step to see results'}{#if !disableMock}
{' or'}
<button
class="text-blue-500 hover:text-blue-700 underline"
onclick={() => {
const newMock = {
enabled: true,
return_value: mock?.return_value ?? { example: 'value' }
}
onUpdateMock?.(newMock)
}}>pin data<Pin size={16} class="inline" /></button
>{:else}.{/if}
</p>
</div>
{/if}
</div>
</div>
{#if initial && !mock?.enabled}
<span
in:fade
class="-mb-1 -mt-0.5 w-full text-right pr-4 dark:text-gray-500 text-gray-400 font-normal text-2xs py-0"
>Run loaded from history</span
>
{/if}
</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}
{#snippet historyPicker()}
<Popover
bind:this={stepHistoryPopover}
floatingConfig={{
strategy: 'fixed',
placement: 'left-start',
offset: historyOffset,
gutter: 0 // hack to make offset effective, see https://github.com/melt-ui/melt-ui/issues/528
}}
contentClasses="w-[225px] overflow-hidden"
{closeOnOutsideClick}
usePointerDownOutside={closeOnOutsideClick}
disablePopup={!!connectingData || jsonView}
bind:isOpen={historyOpen}
>
{#snippet trigger()}
<Button
color="light"
size="xs2"
variant="contained"
btnClasses="bg-surface h-[27px]"
startIcon={{ icon: History }}
nonCaptureEvent
/>
{/snippet}
{#snippet content()}
<div class="rounded-[inherit]" style={`height: ${popoverHeight}px`}>
<StepHistory
{moduleId}
{getLogs}
on:select={selectJobFromHistory}
mockValue={mock?.return_value}
mockEnabled={mock?.enabled}
{path}
{noHistory}
/>
</div>
{/snippet}
</Popover>
{/snippet}
<style>
.dbl-click-editable {
cursor: text;
transition: all 0.2s ease;
}
.dbl-click-editable:hover {
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.5) inset;
}
</style>