mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 08:02:38 +00:00
fix: keep the flow graph mounted across step-panel placement changes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016eu85JrwsztjfXGNQAATRz
This commit is contained in:
co-authored by
Claude Opus 5
parent
e2defaceed
commit
80d6fb7eb3
@@ -84,10 +84,11 @@
|
||||
preference: () => panelController.preference,
|
||||
setPreference: (preference) => {
|
||||
if (preference === panelController.preference) return
|
||||
// Moving the panel must not lose what it was showing: docked, it is always on screen,
|
||||
// so the dialog it becomes has to open on arrival. The reverse is handled by the
|
||||
// effect below, which closes a dialog that is no longer rendered.
|
||||
const wasVisible = panelMode === 'docked' || stepModalOpen
|
||||
// Moving the panel must not lose what it was showing, so a panel that was on screen
|
||||
// reopens as a dialog. Docked is not enough on its own: under hideDefaultInputs with
|
||||
// nothing selected the pane renders nothing, and detaching would open an empty dialog.
|
||||
// The reverse is handled by the effect below, which closes a dialog no longer rendered.
|
||||
const wasVisible = (panelMode === 'docked' && hasSideContent) || stepModalOpen
|
||||
panelController.preference = preference
|
||||
stepModalOpen = panelController.mode === 'modal' && wasVisible
|
||||
}
|
||||
@@ -95,6 +96,7 @@
|
||||
// Whether the panel has anything to show. Kept apart from panelMode so the double-click
|
||||
// gesture stays live under hideDefaultInputs, where nothing shows until a step is picked.
|
||||
let hasSideContent = $derived(!noSide && !(hideDefaultInputs && stepDetail == undefined))
|
||||
let panelDocked = $derived(hasSideContent && panelMode === 'docked')
|
||||
|
||||
// A move back to 'docked' — the viewer got wider — would otherwise leave a dialog open
|
||||
// over a panel that is already visible beside the graph.
|
||||
@@ -190,19 +192,25 @@
|
||||
bind:clientWidth={availableWidth}
|
||||
class="w-full h-full min-h-0 relative"
|
||||
>
|
||||
{#if !noGraph && hasSideContent && panelMode === 'docked'}
|
||||
{#if noGraph}
|
||||
{#if hasSideContent}
|
||||
{@render side()}
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- The graph keeps one pane across every transition, and only the step pane comes and
|
||||
goes. Rendering it in two branches instead would re-create FlowGraphV2 whenever the
|
||||
panel moves — losing pan, zoom and the selected node — and every embed under 1280
|
||||
would mount it twice, since width starts at 0 and 0 resolves to docked. -->
|
||||
<Splitpanes class="w-full h-full">
|
||||
<Pane size={66} minSize={25}>
|
||||
<Pane size={panelDocked ? 66 : 100} minSize={25}>
|
||||
{@render graph()}
|
||||
</Pane>
|
||||
<Pane size={34} minSize={15}>
|
||||
{@render side()}
|
||||
</Pane>
|
||||
{#if panelDocked}
|
||||
<Pane size={34} minSize={15}>
|
||||
{@render side()}
|
||||
</Pane>
|
||||
{/if}
|
||||
</Splitpanes>
|
||||
{:else if !noGraph}
|
||||
{@render graph()}
|
||||
{:else if hasSideContent}
|
||||
{@render side()}
|
||||
{/if}
|
||||
|
||||
{#if panelMode === 'modal' && !stepModalOpen}
|
||||
@@ -215,7 +223,13 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Modal bind:open={stepModalOpen} title={stepModalTitle} kind="X" titleBadgeFirst class="max-w-4xl">
|
||||
<Modal
|
||||
bind:open={stepModalOpen}
|
||||
title={stepModalTitle}
|
||||
kind="X"
|
||||
titleBadgeFirst
|
||||
class="sm:max-w-4xl"
|
||||
>
|
||||
{#snippet titleBadge()}
|
||||
{#if stepModalBadge}
|
||||
<Badge color="indigo" small class="shrink-0 !py-0 leading-4">{stepModalBadge}</Badge>
|
||||
|
||||
@@ -1077,7 +1077,7 @@
|
||||
{/if}
|
||||
{:else if flowModule.value.type === 'flow'}
|
||||
{#key forceReload}
|
||||
<FlowPathViewer path={flowModule.value.path} />
|
||||
<FlowPathViewer noSide path={flowModule.value.path} />
|
||||
{/key}
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { FlowModule } from '$lib/gen'
|
||||
import { stepLabel } from './stepLabel'
|
||||
|
||||
function mod(id: string, value: any, summary?: string): FlowModule {
|
||||
return { id, value, ...(summary ? { summary } : {}) } as FlowModule
|
||||
}
|
||||
|
||||
describe('stepLabel', () => {
|
||||
it('prefers the summary over every derived label', () => {
|
||||
expect(stepLabel(mod('a', { type: 'forloopflow', parallel: true }, 'Per line item'))).toBe(
|
||||
'Per line item'
|
||||
)
|
||||
expect(stepLabel(mod('failure', { type: 'rawscript', language: 'bun' }, 'Tell Slack'))).toBe(
|
||||
'Tell Slack'
|
||||
)
|
||||
})
|
||||
|
||||
// The ordering that a reorder would silently break: the id checks sit between the composite
|
||||
// types and the leaf script types, so these keep their role rather than reading as their body.
|
||||
it('names the failure and preprocessor modules by their role, not their script', () => {
|
||||
expect(stepLabel(mod('failure', { type: 'rawscript', language: 'bun' }))).toBe('Error handler')
|
||||
expect(stepLabel(mod('preprocessor', { type: 'rawscript', language: 'python3' }))).toBe(
|
||||
'Preprocessor'
|
||||
)
|
||||
})
|
||||
|
||||
it('lets a composite type win over the id checks', () => {
|
||||
expect(stepLabel(mod('failure', { type: 'branchone' }))).toBe('Run one branch')
|
||||
})
|
||||
|
||||
it('appends one parenthesised suffix per set flag, in order', () => {
|
||||
expect(
|
||||
stepLabel(
|
||||
mod('b', { type: 'forloopflow', parallel: true, skip_failures: true, squash: true })
|
||||
)
|
||||
).toBe('For loop (parallel) (skip failures) (squash)')
|
||||
expect(stepLabel(mod('b', { type: 'forloopflow' }))).toBe('For loop')
|
||||
expect(stepLabel(mod('c', { type: 'whileloopflow', squash: true }))).toBe('While loop (squash)')
|
||||
expect(stepLabel(mod('d', { type: 'branchall', parallel: true }))).toBe(
|
||||
'Run all branches (parallel)'
|
||||
)
|
||||
})
|
||||
|
||||
it('names the leaf types', () => {
|
||||
expect(stepLabel(mod('e', { type: 'rawscript', language: 'python3' }))).toBe(
|
||||
'Inline python3 script'
|
||||
)
|
||||
expect(stepLabel(mod('f', { type: 'script' }))).toBe('Workspace script')
|
||||
expect(stepLabel(mod('g', { type: 'aiagent' }))).toBe('AI Agent')
|
||||
expect(stepLabel(mod('h', { type: 'flow' }))).toBe('Inner flow')
|
||||
expect(stepLabel(mod('i', { type: 'identity' }))).toBe('Identity')
|
||||
})
|
||||
|
||||
it('returns an empty label for an unknown type rather than throwing', () => {
|
||||
expect(stepLabel(mod('j', { type: 'something_new' }))).toBe('')
|
||||
expect(stepLabel(mod('k', undefined))).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -30,13 +30,24 @@
|
||||
short: 'height: 260px; width: 100%'
|
||||
}
|
||||
|
||||
/** Stamped on every flow this harness deploys, and checked before it overwrites one. */
|
||||
const FIXTURE_MARK = 'Deployed by /dev/flow_path_viewer. Safe to delete.'
|
||||
|
||||
async function upsert(workspace: string, p: string, flow: OpenFlow) {
|
||||
const body = { path: p, ...flow, deployment_message: 'dev fixture' }
|
||||
if (await FlowService.existsFlowByPath({ workspace, path: p })) {
|
||||
await FlowService.updateFlow({ workspace, path: p, requestBody: body })
|
||||
} else {
|
||||
const body = { path: p, ...flow, description: FIXTURE_MARK, deployment_message: 'dev fixture' }
|
||||
if (!(await FlowService.existsFlowByPath({ workspace, path: p }))) {
|
||||
await FlowService.createFlow({ workspace, requestBody: body })
|
||||
return
|
||||
}
|
||||
// A path this harness happens to want can already hold someone's real flow. Redeploying
|
||||
// over it would be a silent, versioned overwrite, so only ever replace our own.
|
||||
const existing = await FlowService.getFlowByPath({ workspace, path: p })
|
||||
if (existing.description !== FIXTURE_MARK) {
|
||||
throw new Error(
|
||||
`${p} already exists and was not created by this page — delete it or point the harness elsewhere`
|
||||
)
|
||||
}
|
||||
await FlowService.updateFlow({ workspace, path: p, requestBody: body })
|
||||
}
|
||||
|
||||
async function seed(workspace: string, p: string) {
|
||||
@@ -58,10 +69,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
// The page deploys flows into whatever workspace you are in, so it exists only in a dev build.
|
||||
const enabled = import.meta.env.DEV
|
||||
|
||||
$effect(() => {
|
||||
const workspace = $workspaceStore
|
||||
const p = path
|
||||
if (!workspace || !$userStore) return
|
||||
if (!enabled || !workspace || !$userStore) return
|
||||
// seed() writes seeded/seeding, so the guard has to read them outside the dependency set
|
||||
untrack(() => {
|
||||
if (seeded !== p && !seeding) seed(workspace, p)
|
||||
@@ -69,47 +83,53 @@
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="h-screen w-full flex flex-col min-h-0">
|
||||
<div class="flex flex-wrap items-center gap-4 border-b px-4 py-2 text-xs bg-surface-secondary">
|
||||
<span class="font-semibold">FlowPathViewer</span>
|
||||
<span class="text-tertiary font-mono">{path}</span>
|
||||
|
||||
<Toggle bind:checked={noSide} size="xs" options={{ right: 'noSide' }} />
|
||||
<Toggle
|
||||
bind:checked={fillAvailableHeight}
|
||||
size="xs"
|
||||
options={{ right: 'fillAvailableHeight' }}
|
||||
/>
|
||||
|
||||
<ToggleButtonGroup bind:selected={frame} noWFull>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value="full" label="Full page" {item} />
|
||||
<ToggleButton value="panel" label="Raw-app pane (55%)" {item} />
|
||||
<ToggleButton value="short" label="Short (260px)" {item} />
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
|
||||
<Button
|
||||
unifiedSize="xs"
|
||||
variant="default"
|
||||
loading={seeding}
|
||||
onclick={() => $workspaceStore && seed($workspaceStore, path)}
|
||||
>
|
||||
Re-seed
|
||||
</Button>
|
||||
{#if !enabled}
|
||||
<div class="p-8 text-sm text-secondary">
|
||||
This page deploys fixture flows into the current workspace, so it runs only in a dev build.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="h-screen w-full flex flex-col min-h-0">
|
||||
<div class="flex flex-wrap items-center gap-4 border-b px-4 py-2 text-xs bg-surface-secondary">
|
||||
<span class="font-semibold">FlowPathViewer</span>
|
||||
<span class="text-tertiary font-mono">{path}</span>
|
||||
|
||||
<div class="grow min-h-0 p-4">
|
||||
<div class="border rounded min-h-0 flex flex-col overflow-hidden" style={frameStyle[frame]}>
|
||||
{#if error}
|
||||
<div class="p-4 text-xs text-red-600 font-mono whitespace-pre-wrap">{error}</div>
|
||||
{:else if seeded}
|
||||
{#key seeded}
|
||||
<FlowPathViewer path={seeded} {noSide} {fillAvailableHeight} />
|
||||
{/key}
|
||||
{:else}
|
||||
<div class="p-4 text-xs text-tertiary">Seeding the fixture flow…</div>
|
||||
{/if}
|
||||
<Toggle bind:checked={noSide} size="xs" options={{ right: 'noSide' }} />
|
||||
<Toggle
|
||||
bind:checked={fillAvailableHeight}
|
||||
size="xs"
|
||||
options={{ right: 'fillAvailableHeight' }}
|
||||
/>
|
||||
|
||||
<ToggleButtonGroup bind:selected={frame} noWFull>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value="full" label="Full page" {item} />
|
||||
<ToggleButton value="panel" label="Raw-app pane (55%)" {item} />
|
||||
<ToggleButton value="short" label="Short (260px)" {item} />
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
|
||||
<Button
|
||||
unifiedSize="xs"
|
||||
variant="default"
|
||||
loading={seeding}
|
||||
onclick={() => $workspaceStore && seed($workspaceStore, path)}
|
||||
>
|
||||
Re-seed
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="grow min-h-0 p-4">
|
||||
<div class="border rounded min-h-0 flex flex-col overflow-hidden" style={frameStyle[frame]}>
|
||||
{#if error}
|
||||
<div class="p-4 text-xs text-red-600 font-mono whitespace-pre-wrap">{error}</div>
|
||||
{:else if seeded}
|
||||
{#key seeded}
|
||||
<FlowPathViewer path={seeded} {noSide} {fillAvailableHeight} />
|
||||
{/key}
|
||||
{:else}
|
||||
<div class="p-4 text-xs text-tertiary">Seeding the fixture flow…</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -36,8 +36,6 @@ export const subFixtureFlow: OpenFlow = {
|
||||
*/
|
||||
export const fixtureFlow = (subflowPath: string): OpenFlow => ({
|
||||
summary: 'Order fulfilment (dev fixture)',
|
||||
description:
|
||||
'Fake flow rendered by /dev/flow_path_viewer. Edit fixtureFlow.ts and hit Re-seed to change the graph.',
|
||||
schema: {
|
||||
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
||||
type: 'object',
|
||||
|
||||
Reference in New Issue
Block a user