From e78d1d6fb5e599239afe2d597544a2654dcc972f Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Wed, 30 Sep 2026 13:34:28 +0200 Subject: [PATCH] feat: assets-only trigger chip under the title, editable and selectable Co-Authored-By: Claude Opus 5.5 (1M context) --- .../assets/AssetGraph/AssetGraphCanvas.svelte | 67 +++++++++++++++-- .../assets/AssetGraph/AssetNode.svelte | 75 +++++++++++++------ .../assets/AssetGraph/PipelineNodeCard.svelte | 20 ++--- .../assets/AssetGraph/assetGraphLayout.ts | 4 +- 4 files changed, 124 insertions(+), 42 deletions(-) diff --git a/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte b/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte index 7e442f1d04..40c3752f13 100644 --- a/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte @@ -904,23 +904,57 @@ let model = $derived(build(graph)) - /** The label of the chip naming what runs an asset's producer. */ - function triggerChip(t: (AssetUpstream & { multiple: false })['trigger']): { + // The trigger chip last clicked in the assets-only view, by trigger node id: it + // opens a drawer rather than a selection, so it is highlighted until the next pick. + let activeTriggerId = $state(undefined) + $effect(() => { + void selection + activeTriggerId = undefined + }) + + /** The chip naming what runs an asset's producer, and the editor it opens: the + * same routes the trigger's own node takes. */ + function triggerChip( + t: (AssetUpstream & { multiple: false })['trigger'], + scriptPath: string + ): { label: string + nodeId?: string missing?: boolean draft?: boolean + onOpen?: () => void } { if (!t) return { label: 'Manual' } if (t.kind === 'asset') return { label: 'On asset change' } - const style = TRIGGER_NODE_STYLE[t.kind as TriggerNodeKind] + const kind = t.kind as NativeTriggerKind + const style = TRIGGER_NODE_STYLE[kind] const label = t.kind === 'schedule' && t.data?.schedule ? (describeSchedule(t.data.schedule, t.data.timezone) ?? `Schedule (${t.data.schedule})`) : (style?.label ?? t.kind) + const rowless = kind === 'webhook' || kind === 'data_upload' + const missing = !!t.data?.missing && !rowless + const open = + kind === 'webhook' + ? onOpenWebhook && (() => onOpenWebhook(scriptPath)) + : kind === 'data_upload' + ? onOpenDataUpload && (() => onOpenDataUpload(scriptPath)) + : missing + ? onCreateMissingTrigger && (() => onCreateMissingTrigger(kind, scriptPath)) + : t.data?.ref && onEditTrigger + ? () => onEditTrigger(kind, t.data.ref, scriptPath) + : undefined return { label, - missing: t.data?.missing && t.kind !== 'webhook' && t.kind !== 'data_upload', - draft: t.data?.draft + nodeId: t.nodeId, + missing, + draft: t.data?.draft, + onOpen: open + ? () => { + activeTriggerId = t.nodeId + open() + } + : undefined } } @@ -948,12 +982,13 @@ : u.multiple || !r ? { multiple: true } : { + runnableId: u.runnableId, path: r.path, summary: r.summary, language: r.language, unsaved: r.unsaved, runState: r.runState, - trigger: triggerChip(u.trigger), + trigger: triggerChip(u.trigger, r.path), onOpen: () => onselect?.({ kind: 'runnable', @@ -1098,11 +1133,28 @@ else if (!boundPick.eligible.has(n.id)) boundClass = 'wm-bound-dim' } const dbtClass = dbtEmphasisIds.has(n.id) ? 'wm-dbt-linked' : undefined + // The chips' selected look rides on the positioned nodes, so a selection + // never re-runs the layout. + const up = n.type === 'asset' ? n.data.upstream : undefined + const data = + up?.runnableId !== undefined + ? { + ...n.data, + upstream: { + ...up, + selected: up.runnableId === selectedId, + trigger: { + ...up.trigger, + selected: !!up.trigger.nodeId && up.trigger.nodeId === activeTriggerId + } + } + } + : n.data return { id: n.id, type: n.type, position: { x: p.x + xCenter, y: p.y + 40 }, - data: n.data, + data, class: boundClass ?? dbtClass ?? runClass ?? assetClass, selected: n.id === selectedId, // All nodes non-draggable: the layout is sugiyama-computed, @@ -1423,6 +1475,7 @@ ) { return } + activeTriggerId = undefined onselect(undefined) } diff --git a/frontend/src/lib/components/assets/AssetGraph/AssetNode.svelte b/frontend/src/lib/components/assets/AssetGraph/AssetNode.svelte index 6a38b0c433..e116270668 100644 --- a/frontend/src/lib/components/assets/AssetGraph/AssetNode.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/AssetNode.svelte @@ -40,13 +40,22 @@ | { none: true } | { multiple: true } | { + runnableId: string path: string summary?: string language?: ScriptLang unsaved?: boolean runState?: RunnableRunState - trigger: { label: string; missing?: boolean; draft?: boolean } + trigger: { + label: string + missing?: boolean + draft?: boolean + /** Opens the trigger's editor; unset for what has none (manual, asset change). */ + onOpen?: () => void + selected?: boolean + } onOpen: () => void + selected?: boolean } export type AssetProducer = { @@ -179,6 +188,8 @@ let showAdd = $derived(data.onAddScript != undefined) let upstream = $derived(data.upstream) + const CHIP_SELECTED = + 'bg-surface-accent-selected border-border-selected hover:bg-surface-accent-selected text-accent' let producer = $derived(upstream && 'path' in upstream ? upstream : undefined) // dbt badge. `materialized` is dbt's own word rather than the Windmill @@ -247,30 +258,47 @@ ) - {#snippet upstreamRow()} - {#if upstream} - e.stopPropagation()} + onkeydown={(e) => e.stopPropagation()} + onclick={(e) => { + e.stopPropagation() + trigger.onOpen?.() + }} > + {trigger.label}{trigger.draft ? ' · draft' : ''} + + {:else} + {#if 'none' in upstream} External source - {:else if producer} - {producer.trigger.label}{producer.trigger.draft ? ' · draft' : ''} + {:else if trigger} + {trigger.label} {:else} Multiple upstream nodes {/if} {/if} - {/snippet} + {/if} +{/snippet}