feat: assets-only trigger chip under the title, editable and selectable

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-09-30 13:34:28 +02:00
co-authored by Claude Opus 5.5
parent dcd2fc4912
commit e78d1d6fb5
4 changed files with 124 additions and 42 deletions
@@ -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<string | undefined>(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)
}
</script>
@@ -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 @@
)
</script>
{#snippet upstreamRow()}
{#if upstream}
<span
class={twMerge(
'truncate rounded-md border px-1.5 py-0.5 text-3xs leading-none',
producer?.trigger.missing
? 'border-red-300 dark:border-red-600 text-red-700 dark:text-red-300'
: 'border-gray-300 dark:border-gray-600 text-secondary',
producer?.trigger.draft && 'border-dashed'
)}
title={producer?.trigger.missing
? `No ${producer.trigger.label} trigger targets ${producer.path} yet`
: undefined}
{#snippet upstreamRow()}
{#if upstream}
{@const trigger = producer?.trigger}
{@const chipClass = twMerge(
'truncate rounded-md border px-1.5 py-0.5 text-3xs leading-none font-normal',
trigger?.missing
? 'border-red-300 dark:border-red-600 text-red-700 dark:text-red-300'
: 'border-gray-300 dark:border-gray-600 text-secondary',
trigger?.draft && 'border-dashed',
trigger?.onOpen && 'hover:bg-surface-hover cursor-pointer',
trigger?.selected && CHIP_SELECTED
)}
{#if trigger?.onOpen}
<button
type="button"
class={chipClass}
title={trigger.missing
? `No ${trigger.label} trigger targets ${producer?.path} yet: click to create one`
: `Edit the ${trigger.label} trigger`}
onpointerdown={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
onclick={(e) => {
e.stopPropagation()
trigger.onOpen?.()
}}
>
{trigger.label}{trigger.draft ? ' · draft' : ''}
</button>
{:else}
<span class={chipClass}>
{#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}
</span>
{/if}
{/snippet}
{/if}
{/snippet}
<!-- onmouseenter/leave on the wrapper (not the inner card) so the run
button — which floats outside the card — keeps the hover state alive
@@ -294,7 +322,8 @@
tooltip={data.error ? `${data.path}: ${data.error}` : data.path}
{selected}
tone={data.error ? 'error' : undefined}
footer={upstream ? upstreamRow : undefined}
subtitle={upstream ? upstreamRow : undefined}
surface={upstream ? 'primary' : 'secondary'}
>
{#snippet icon()}
<!-- Data identity carries the accent (luminance blue), pairing with
@@ -413,11 +442,15 @@
{/if}
<button
type="button"
class="shrink-0 h-5 min-w-5 px-1 grid place-items-center rounded-md border bg-surface text-secondary hover:bg-surface-hover {producer.unsaved
? 'border-dashed border-gray-400 dark:border-gray-500'
: 'border-gray-300 dark:border-gray-600'}"
class={twMerge(
'shrink-0 h-5 min-w-5 px-1 grid place-items-center rounded-md border bg-surface text-secondary hover:bg-surface-hover border-gray-300 dark:border-gray-600',
producer.unsaved && 'border-dashed border-gray-400 dark:border-gray-500',
producer.selected && CHIP_SELECTED
)}
title={`Open ${producer.summary ? `${producer.summary} (${producer.path})` : producer.path}${producer.unsaved ? ' · draft' : ''}`}
aria-label="Open the script that produces this asset"
onpointerdown={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
onclick={(e) => {
e.stopPropagation()
producer?.onOpen()
@@ -29,8 +29,8 @@ and the controls hung around the card.
/** Between the icon and the text: a live status the eye should find first. */
leading?: Snippet
trailing?: Snippet
/** A second row under the title, as wide as the card. */
footer?: Snippet
/** A line under the title, in the text column. */
subtitle?: Snippet
}
let {
@@ -45,12 +45,12 @@ and the controls hung around the card.
onclick,
leading,
trailing,
footer
subtitle
}: Props = $props()
const cardClass = $derived(
twMerge(
'flex flex-col justify-center w-full text-left font-normal rounded-md drop-shadow-sm overflow-hidden border transition-colors',
'flex items-center w-full text-left font-normal rounded-md drop-shadow-sm overflow-hidden border transition-colors',
surface === 'primary' ? 'bg-surface' : 'bg-surface-secondary',
'border-gray-400 dark:border-gray-600 hover:border-gray-500 dark:hover:border-gray-500',
draft && 'border-dashed border-gray-400 dark:border-gray-500',
@@ -69,7 +69,7 @@ and the controls hung around the card.
'bg-amber-50 dark:bg-amber-900/30 border-amber-400 dark:border-amber-600 animate-pulse'
)
)
const minHeight = $derived(PIPELINE_NODE_HEIGHT + (footer ? PIPELINE_NODE_EXTRA_ROW : 0))
const minHeight = $derived(PIPELINE_NODE_HEIGHT + (subtitle ? PIPELINE_NODE_EXTRA_ROW : 0))
const toneText = $derived(
tone === 'danger'
? 'text-red-700 dark:text-red-400'
@@ -82,13 +82,6 @@ and the controls hung around the card.
</script>
{#snippet body()}
<span class="flex items-center w-full">{@render row()}</span>
{#if footer}
<span class="flex items-center gap-1 w-full min-w-0 pl-3 pr-2 pb-1.5">{@render footer()}</span>
{/if}
{/snippet}
{#snippet row()}
<span class="shrink-0 ml-3 mr-2.5 flex items-center">{@render icon()}</span>
{@render leading?.()}
<span class="flex flex-col min-w-0 flex-1 pr-1 py-1 leading-tight">
@@ -96,6 +89,9 @@ and the controls hung around the card.
<span class={twMerge('text-xs truncate text-emphasis', selected && 'text-accent', toneText)}
>{title}</span
>
{#if subtitle}
<span class="flex items-center min-w-0 mt-1">{@render subtitle()}</span>
{/if}
</span>
{#if trailing}
<span class="flex items-center shrink-0 pr-2">{@render trailing()}</span>
@@ -18,8 +18,8 @@ const SIBLING_GAP = NODE.gap.horizontal
// into each other's columns — but only ~2×, so they don't drift far apart.
const COMPONENT_GAP = SIBLING_GAP * 2
/** Extra height of an assets-only node: the row of upstream chips under the title. */
export const PIPELINE_NODE_EXTRA_ROW = 24
/** Extra height of an assets-only node: the trigger chip under its title. */
export const PIPELINE_NODE_EXTRA_ROW = 16
interface GraphInput {
nodes: Array<{ id: string; data: AssetGraphNodeData }>