mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
feat: show flow step detail inside the graph tab on narrow detail layouts (#11168)
* feat: show flow step detail as a page inside the graph tab on narrow detail layouts * fix: pad the detail step tab, wrapped header row and raise the tabbed layout breakpoint * fix: keep the step header pinned and switch to triggers on every trigger node tap
This commit is contained in:
@@ -19,6 +19,7 @@
|
||||
import { Copy, Expand } from 'lucide-svelte'
|
||||
import HighlightTheme from './HighlightTheme.svelte'
|
||||
import LanguageIcon from './common/languageIcons/LanguageIcon.svelte'
|
||||
import FlowGraphViewerStepHeader from './FlowGraphViewerStepHeader.svelte'
|
||||
|
||||
interface Props {
|
||||
schema?: any | undefined
|
||||
@@ -28,6 +29,8 @@
|
||||
// The workspace the viewed flow belongs to (differs from the nav workspace in fork/session
|
||||
// editors); used to qualify resource links.
|
||||
workspace?: string
|
||||
/** Given, the step header starts with a back control that calls it. */
|
||||
onBack?: () => void
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -35,7 +38,8 @@
|
||||
stepDetail = undefined,
|
||||
jobScriptHash = undefined,
|
||||
hideDefaultInputs = false,
|
||||
workspace = undefined
|
||||
workspace = undefined,
|
||||
onBack = undefined
|
||||
}: Props = $props()
|
||||
let ws = $derived(workspace ?? $workspaceStore)
|
||||
let codeViewer: Drawer | undefined = $state()
|
||||
@@ -104,57 +108,20 @@
|
||||
{/if}
|
||||
</div>
|
||||
{:else if stepDetail == 'Input'}
|
||||
<FlowGraphViewerStepHeader {stepDetail} {onBack} />
|
||||
{#if schema}
|
||||
<SchemaViewer {schema} />
|
||||
{:else}
|
||||
<p class="font-medium text-secondary text-center pt-4 pb-8"> No input schema </p>
|
||||
{/if}
|
||||
{:else if stepDetail == 'Result'}
|
||||
<FlowGraphViewerStepHeader {stepDetail} {onBack} />
|
||||
<p class="font-medium text-secondary text-center pt-4 pb-8"> End of the flow </p>
|
||||
{:else if typeof stepDetail != 'string' && stepDetail.value}
|
||||
<!-- A direct child of the scrolling root: a sticky row can only hold within its parent's
|
||||
box, so wrapped with the path link below it would scroll away with that wrapper. -->
|
||||
<FlowGraphViewerStepHeader {stepDetail} {onBack} />
|
||||
<div class="">
|
||||
<div class="sticky top-0 bg-surface w-full flex items-center py-2">
|
||||
{#if stepDetail.id && stepDetail.id != 'failure' && stepDetail.id != 'preprocessor'}
|
||||
<Badge color="indigo">
|
||||
{stepDetail.id}
|
||||
</Badge>
|
||||
{/if}
|
||||
<span
|
||||
class={twMerge(
|
||||
'font-semibold text-emphasis text-sm',
|
||||
stepDetail.id !== 'failure' && stepDetail.id !== 'preprocessor' ? 'ml-2' : ''
|
||||
)}
|
||||
>
|
||||
{#if stepDetail.summary}
|
||||
{stepDetail.summary}
|
||||
{:else if stepDetail.value.type == 'identity'}
|
||||
Identity
|
||||
{:else if stepDetail.value.type == 'forloopflow'}
|
||||
For loop {#if stepDetail.value.parallel}(parallel){/if}
|
||||
{#if stepDetail.value.skip_failures}(skip failures){/if}
|
||||
{#if stepDetail.value.squash}(squash){/if}
|
||||
{:else if stepDetail.value.type == 'branchall'}
|
||||
Run all branches {#if stepDetail.value.parallel}(parallel){/if}
|
||||
{:else if stepDetail.value.type == 'branchone'}
|
||||
Run one branch
|
||||
{:else if stepDetail.value.type == 'flow'}
|
||||
Inner flow
|
||||
{:else if stepDetail.value.type == 'whileloopflow'}
|
||||
While loop {#if stepDetail.value.skip_failures}(skip failures){/if}
|
||||
{#if stepDetail.value.squash}(squash){/if}
|
||||
{:else if stepDetail.id === 'failure'}
|
||||
Error handler
|
||||
{:else if stepDetail.id === 'preprocessor'}
|
||||
Preprocessor
|
||||
{:else if stepDetail.value.type == 'rawscript'}
|
||||
Inline {stepDetail.value.language} script
|
||||
{:else if stepDetail.value.type == 'script'}
|
||||
Workspace script
|
||||
{:else if stepDetail.value.type == 'aiagent'}
|
||||
AI Agent
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{#if stepDetail.value.type == 'script'}
|
||||
<div class="pb-2">
|
||||
<a
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
import type { FlowModule } from '$lib/gen'
|
||||
import { Badge, Button } from './common'
|
||||
import { ArrowLeft } from 'lucide-svelte'
|
||||
|
||||
interface Props {
|
||||
/** A module, or the graph's pseudo-nodes by id (`Input`, `Result`). */
|
||||
stepDetail: FlowModule | string
|
||||
/** Given, the row starts with a back control; the caller decides where back leads. */
|
||||
onBack?: () => void
|
||||
}
|
||||
|
||||
let { stepDetail, onBack = undefined }: Props = $props()
|
||||
|
||||
const module = $derived(typeof stepDetail === 'string' ? undefined : stepDetail)
|
||||
// The error handler and the preprocessor are named by their role, not by an id badge.
|
||||
const showId = $derived(
|
||||
module?.id !== undefined && module.id !== 'failure' && module.id !== 'preprocessor'
|
||||
)
|
||||
|
||||
const title = $derived.by((): string => {
|
||||
if (typeof stepDetail === 'string') {
|
||||
if (stepDetail === 'Input') return 'Flow inputs'
|
||||
if (stepDetail === 'Result') return 'Result'
|
||||
return stepDetail
|
||||
}
|
||||
if (stepDetail.summary) return stepDetail.summary
|
||||
if (stepDetail.id === 'failure') return 'Error handler'
|
||||
if (stepDetail.id === 'preprocessor') return 'Preprocessor'
|
||||
const v = stepDetail.value
|
||||
switch (v?.type) {
|
||||
case 'identity':
|
||||
return 'Identity'
|
||||
case 'forloopflow':
|
||||
return (
|
||||
'For loop' +
|
||||
(v.parallel ? ' (parallel)' : '') +
|
||||
(v.skip_failures ? ' (skip failures)' : '') +
|
||||
(v.squash ? ' (squash)' : '')
|
||||
)
|
||||
case 'whileloopflow':
|
||||
return (
|
||||
'While loop' + (v.skip_failures ? ' (skip failures)' : '') + (v.squash ? ' (squash)' : '')
|
||||
)
|
||||
case 'branchall':
|
||||
return 'Run all branches' + (v.parallel ? ' (parallel)' : '')
|
||||
case 'branchone':
|
||||
return 'Run one branch'
|
||||
case 'flow':
|
||||
return 'Inner flow'
|
||||
case 'rawscript':
|
||||
return `Inline ${v.language} script`
|
||||
case 'script':
|
||||
return 'Workspace script'
|
||||
case 'aiagent':
|
||||
return 'AI Agent'
|
||||
default:
|
||||
return stepDetail.id
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- -top-2: the row pins at the scroll container's content edge, and FlowGraphViewerStep pads
|
||||
its root by that much, so at top-0 the body would show through the padding above the row. -->
|
||||
<div class="sticky -top-2 z-10 flex w-full items-center gap-2 bg-surface py-2">
|
||||
{#if onBack}
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="subtle"
|
||||
iconOnly
|
||||
startIcon={{ icon: ArrowLeft }}
|
||||
title="Back to the flow graph"
|
||||
onclick={onBack}
|
||||
/>
|
||||
{/if}
|
||||
{#if showId && module}
|
||||
<Badge color="indigo">{module.id}</Badge>
|
||||
{/if}
|
||||
<span class="min-w-0 truncate text-sm font-semibold text-emphasis" {title}>{title}</span>
|
||||
</div>
|
||||
@@ -137,7 +137,7 @@
|
||||
<div class="border-b">
|
||||
<div class="mx-auto">
|
||||
<div
|
||||
class="flex w-full flex-wrap md:flex-nowrap justify-end gap-x-2 gap-y-4 items-center min-h-12"
|
||||
class="flex w-full flex-wrap md:flex-nowrap justify-end gap-x-2 gap-y-4 items-center min-h-12 py-2 md:py-0"
|
||||
>
|
||||
<div class="grow px-2 inline-flex items-center gap-4 min-w-0">
|
||||
<div class={twMerge('min-w-0', $userStore?.operator ? 'pl-10' : '')}>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { Tabs, Tab, TabContent } from '$lib/components/common'
|
||||
import PagedContent from '$lib/components/common/modal/PagedContent.svelte'
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
import DetailPageDetailPanel from './DetailPageDetailPanel.svelte'
|
||||
import FlowViewerInner from '../FlowViewerInner.svelte'
|
||||
@@ -11,10 +12,14 @@
|
||||
forceSmallScreen?: boolean
|
||||
isChatMode?: boolean
|
||||
header?: import('svelte').Snippet
|
||||
form?: import('svelte').Snippet
|
||||
/** `graphInline`: whether the form should carry the flow graph under it. It does in the
|
||||
* split layout; the tabbed layout gives the graph a tab of its own. */
|
||||
form?: import('svelte').Snippet<[{ graphInline: boolean }]>
|
||||
scriptRender?: import('svelte').Snippet
|
||||
save_inputs?: import('svelte').Snippet
|
||||
flow_step?: import('svelte').Snippet
|
||||
/** `onBack`: set where the step is a page pushed over the graph, so its header can lead
|
||||
* back; absent where the step has a tab of its own. */
|
||||
flow_step?: import('svelte').Snippet<[{ onBack?: () => void }]>
|
||||
triggers?: import('svelte').Snippet
|
||||
flow_graph?: import('svelte').Snippet
|
||||
}
|
||||
@@ -34,7 +39,7 @@
|
||||
flow_graph
|
||||
}: Props = $props()
|
||||
|
||||
let mobileTab: 'form' | 'detail' = $state('form')
|
||||
let mobileTab = $state('form')
|
||||
|
||||
let clientWidth = $state(window.innerWidth)
|
||||
|
||||
@@ -44,7 +49,22 @@
|
||||
const triggers_render = $derived(triggers)
|
||||
const flow_graph_render = $derived(flow_graph)
|
||||
|
||||
const useDesktopLayout = $derived(clientWidth >= 768 && !forceSmallScreen)
|
||||
// 1024 (Tailwind `lg`), where the page header also stops collapsing its actions: below it
|
||||
// the split's right pane is under 340px, too narrow for a step's code.
|
||||
const useDesktopLayout = $derived(clientWidth >= 1024 && !forceSmallScreen)
|
||||
|
||||
// The tabbed layout has no Step tab: a step opens as a page pushed over the graph tab, and
|
||||
// the way back is the graph.
|
||||
const graphPage = $derived(selected === 'flow_step' ? 'step' : 'graph')
|
||||
|
||||
/** Show the triggers pane: the right pane's tab in the split layout, the Triggers tab in the
|
||||
* tabbed one. A method rather than a value the caller sets, because asking twice in a row is
|
||||
* two requests — the tab may have been left in between — and a value set to what it already
|
||||
* holds changes nothing. */
|
||||
export function showTriggers() {
|
||||
selected = 'triggers'
|
||||
mobileTab = 'triggers'
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="h-screen w-full" bind:clientWidth>
|
||||
@@ -54,7 +74,7 @@
|
||||
<div class="grow min-h-0 w-full">
|
||||
<Splitpanes>
|
||||
<Pane size={65} minSize={50}>
|
||||
{@render form?.()}
|
||||
{@render form?.({ graphInline: true })}
|
||||
</Pane>
|
||||
<Pane size={35} minSize={15}>
|
||||
<DetailPageDetailPanel bind:selected {isOperator} {flow_json}>
|
||||
@@ -65,7 +85,11 @@
|
||||
{@render save_inputs_render?.()}
|
||||
{/snippet}
|
||||
{#snippet flow_step()}
|
||||
{@render flow_step_render?.()}
|
||||
<!-- No overflow of its own: the step body is the scroll container its sticky
|
||||
header keys on, so it has to be the flex item that shrinks. -->
|
||||
<div class="flex min-h-0 grow flex-col p-2">
|
||||
{@render flow_step_render?.({})}
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet triggers()}
|
||||
{@render triggers_render?.()}
|
||||
@@ -79,12 +103,15 @@
|
||||
<div class="h-full w-full flex flex-col">
|
||||
{@render header?.()}
|
||||
<div class="grow min-h-0 w-full flex flex-col">
|
||||
<Tabs bind:selected={mobileTab} wrapperClass="flex-none">
|
||||
<!-- no-scrollbar: at phone widths the tabs overflow their strip, and a browser with
|
||||
classic scrollbars would spend a track under them, opening a band between the tabs
|
||||
and the content. Wheel, trackpad and drag still scroll the strip. -->
|
||||
<Tabs bind:selected={mobileTab} wrapperClass="flex-none no-scrollbar">
|
||||
<Tab value="form" label={isChatMode ? 'Chat' : 'Run form'} />
|
||||
{#if !isChatMode}
|
||||
<Tab value="saved_inputs" label="Inputs" />
|
||||
{/if}
|
||||
{#if isChatMode && flow_json}
|
||||
{#if flow_json}
|
||||
<Tab value="flow" label="Flow graph" />
|
||||
{/if}
|
||||
{#if !isOperator}
|
||||
@@ -99,7 +126,7 @@
|
||||
{#snippet content()}
|
||||
<div class="grow min-h-0 overflow-y-auto">
|
||||
<TabContent value="form" class="flex flex-col flex-1 h-full">
|
||||
{@render form?.()}
|
||||
{@render form?.({ graphInline: false })}
|
||||
</TabContent>
|
||||
|
||||
<TabContent value="saved_inputs" class="flex flex-col flex-1 h-full">
|
||||
@@ -108,9 +135,9 @@
|
||||
<TabContent value="triggers" class="flex flex-col flex-1 h-full mt-[-2px]">
|
||||
{@render triggers?.()}
|
||||
</TabContent>
|
||||
{#if isChatMode && flow_json}
|
||||
{#if flow_json}
|
||||
<TabContent value="flow" class="flex flex-col flex-1 h-full">
|
||||
{@render flow_graph_render?.()}
|
||||
{@render pagedGraph()}
|
||||
</TabContent>
|
||||
{/if}
|
||||
{#if flow_json}
|
||||
@@ -128,3 +155,34 @@
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<!-- Warmed so a tab reopened on the step page has the graph built before the way back is taken;
|
||||
the pages are absolutely positioned, so each carries its own scroll. -->
|
||||
{#snippet pagedGraph()}
|
||||
<PagedContent
|
||||
warm
|
||||
class="h-full"
|
||||
current={graphPage}
|
||||
onNavigate={(key) => {
|
||||
if (key === 'graph') selected = 'saved_inputs'
|
||||
}}
|
||||
pages={[
|
||||
{ key: 'graph', content: graphPageContent },
|
||||
{ key: 'step', content: stepPageContent }
|
||||
]}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
{#snippet graphPageContent()}
|
||||
<div class="h-full overflow-y-auto flex flex-col">
|
||||
{@render flow_graph_render?.()}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet stepPageContent()}
|
||||
<!-- The step body brings its own inner padding; this outer band brings it level with the
|
||||
Inputs and Export tabs. No overflow of its own, as in the split layout above. -->
|
||||
<div class="flex min-h-0 grow flex-col p-2">
|
||||
{@render flow_step_render?.({ onBack: () => (selected = 'saved_inputs') })}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
@@ -491,6 +491,7 @@
|
||||
let stepDetail: FlowModule | string | undefined = $state(undefined)
|
||||
let rightPaneSelected = $state('saved_inputs')
|
||||
let savedInputsV2: SavedInputsV2 | undefined = $state(undefined)
|
||||
let detailLayout: DetailPageLayout | undefined = $state(undefined)
|
||||
let flowHistory: FlowHistory | undefined = $state(undefined)
|
||||
let path = $derived(page.params.path ?? '')
|
||||
|
||||
@@ -546,6 +547,7 @@
|
||||
{/if}
|
||||
|
||||
<DetailPageLayout
|
||||
bind:this={detailLayout}
|
||||
bind:selected={rightPaneSelected}
|
||||
isOperator={$userStore?.operator}
|
||||
forceSmallScreen={chatInputEnabled}
|
||||
@@ -560,7 +562,7 @@
|
||||
{#snippet header()}
|
||||
<DetailPageHeader
|
||||
on:seeTriggers={() => {
|
||||
rightPaneSelected = 'triggers'
|
||||
detailLayout?.showTriggers()
|
||||
}}
|
||||
{mainButtons}
|
||||
menuItems={getMenuItems(flow, deployUiSettings)}
|
||||
@@ -596,7 +598,7 @@
|
||||
isFlow
|
||||
selected={rightPaneSelected == 'triggers'}
|
||||
onSelect={async (triggerIndex: number) => {
|
||||
rightPaneSelected = 'triggers'
|
||||
detailLayout?.showTriggers()
|
||||
await tick()
|
||||
triggersState.selectedTriggerIndex = triggerIndex
|
||||
}}
|
||||
@@ -627,7 +629,7 @@
|
||||
{/if}
|
||||
</DetailPageHeader>
|
||||
{/snippet}
|
||||
{#snippet form()}
|
||||
{#snippet form({ graphInline }: { graphInline: boolean })}
|
||||
<div class="px-3">
|
||||
<NoDirectDeployAlert onUpdateCanEditStatus={(v) => (showEditButtons = v)} />
|
||||
</div>
|
||||
@@ -771,7 +773,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if !chatInputEnabled}
|
||||
{#if graphInline}
|
||||
<div class="grow min-h-0">
|
||||
<FlowGraphViewer
|
||||
triggerNode={true}
|
||||
@@ -789,7 +791,7 @@
|
||||
}
|
||||
}}
|
||||
on:triggerDetail={(e) => {
|
||||
rightPaneSelected = 'triggers'
|
||||
detailLayout?.showTriggers()
|
||||
}}
|
||||
noBorder={true}
|
||||
/>
|
||||
@@ -817,10 +819,10 @@
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
{#snippet flow_step()}
|
||||
{#snippet flow_step({ onBack }: { onBack?: () => void })}
|
||||
{#if flow}
|
||||
{#if stepDetail}
|
||||
<FlowGraphViewerStep schema={flow.schema} {stepDetail} />
|
||||
<FlowGraphViewerStep schema={flow.schema} {stepDetail} {onBack} />
|
||||
{/if}
|
||||
{/if}
|
||||
{/snippet}
|
||||
@@ -849,7 +851,7 @@
|
||||
triggerNode={true}
|
||||
download
|
||||
{flow}
|
||||
noSide={false}
|
||||
noSide={true}
|
||||
noBorder
|
||||
minHeight={flowGraphHeight}
|
||||
on:select={(e) => {
|
||||
@@ -862,7 +864,7 @@
|
||||
}
|
||||
}}
|
||||
on:triggerDetail={(e) => {
|
||||
rightPaneSelected = 'triggers'
|
||||
detailLayout?.showTriggers()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user