feat: open an asset's upstream trigger from its pane header

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-09-30 16:47:49 +02:00
co-authored by Claude Opus 5.5
parent 6fb492fabf
commit 1b7bb90c5a
3 changed files with 103 additions and 4 deletions
@@ -21,10 +21,16 @@
Trash2,
X,
Pencil,
ChevronDown
ChevronDown,
Zap
} from 'lucide-svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import { upstreamScriptItems, type UpstreamScript } from './upstreamScriptItems'
import {
upstreamScriptItems,
upstreamTriggerItems,
type UpstreamScript,
type UpstreamTriggerAction
} from './upstreamScriptItems'
import { inferArgs } from '$lib/infer'
import { emptySchema, sendUserToast } from '$lib/utils'
import type { Schema } from '$lib/common'
@@ -164,6 +170,8 @@
* which calls `onEditScript` with the one picked. */
producerScripts?: UpstreamScript[]
onEditScript?: (path: string) => void
/** The selected asset's upstream triggers that can be opened from here. */
producerTriggers?: UpstreamTriggerAction[]
// Pipeline-wide column-lineage graph (built by the parent page from the
// resolved graph). Drives the transitive column-lineage trace shown for a
// selected materialized asset.
@@ -314,6 +322,7 @@
selectionProducers = [],
producerScripts = [],
onEditScript,
producerTriggers = [],
selectionColumnGraph,
selectionColumnLoading = false,
selectionColumnTruncated = false,
@@ -1059,6 +1068,36 @@
{/snippet}
</DropdownV2>
{/if}
{#if selection?.kind === 'asset' && producerTriggers.length === 1}
<Button
variant="default"
unifiedSize="sm"
startIcon={{ icon: Zap }}
onclick={() => producerTriggers[0].onOpen()}
title={producerTriggers[0].detail}
>
{producerTriggers[0].label}
</Button>
{:else if selection?.kind === 'asset' && producerTriggers.length > 1}
<DropdownV2
items={upstreamTriggerItems(producerTriggers)}
placement="bottom-end"
enableFlyTransition
>
{#snippet buttonReplacement()}
<Button
nonCaptureEvent
variant="default"
unifiedSize="sm"
startIcon={{ icon: Zap }}
endIcon={{ icon: ChevronDown }}
title={`${producerTriggers.length} triggers start the scripts that build this asset`}
>
Triggers
</Button>
{/snippet}
</DropdownV2>
{/if}
{#if readOnly && onRequestEdit}
<Button
variant="accent"
@@ -11,6 +11,9 @@
import type { ColumnLineageGraph } from './columnLineageGraph'
import HideButton from '$lib/components/apps/editor/settingsPanel/HideButton.svelte'
import AssetGraphCanvas from './AssetGraphCanvas.svelte'
import { TRIGGER_NODE_STYLE } from './TriggerNode.svelte'
import { describeSchedule } from '$lib/utils/describeCron'
import type { UpstreamTriggerAction } from './upstreamScriptItems'
import type { AssetUpstreamDelete } from './assetsOnlyView'
import AssetGraphDetailsPane from './AssetGraphDetailsPane.svelte'
import PipelineEventLog from './PipelineEventLog.svelte'
@@ -289,6 +292,52 @@
return { path: p.path, summary: r?.summary, language: r?.language }
})
)
// The selected asset's upstream triggers that can be opened from here, each
// with the same action its trigger node has: edit (edit mode), create a
// missing one (edit mode), or the webhook / data-upload drawers.
let producerTriggers = $derived.by((): UpstreamTriggerAction[] => {
const scripts = new Set(producerScripts.map((p) => p.path))
const seen = new Set<string>()
const out: UpstreamTriggerAction[] = []
for (const t of displayGraph.triggers ?? []) {
if (t.trigger_kind === 'asset' || !scripts.has(t.runnable_path)) continue
const kind = t.trigger_kind
const rowless = kind === 'webhook' || kind === 'data_upload'
const key = `${kind}:${rowless || t.missing ? t.runnable_path : t.path}`
if (seen.has(key)) continue
seen.add(key)
const label = TRIGGER_NODE_STYLE[kind]?.label ?? kind
const script = t.runnable_path
let action: UpstreamTriggerAction | undefined
if (kind === 'webhook') {
if (onOpenWebhook)
action = { label: 'Open webhook', detail: script, onOpen: () => onOpenWebhook(script) }
} else if (kind === 'data_upload') {
if (onOpenDataUpload)
action = { label: 'Upload data', detail: script, onOpen: () => onOpenDataUpload(script) }
} else if (t.missing && !rowless) {
if (onCreateMissingTrigger)
action = {
label: `Create ${label} trigger`,
detail: script,
onOpen: () => onCreateMissingTrigger(kind, script)
}
} else if (t.path && onEditTrigger) {
const path = t.path
action = {
label: kind === 'schedule' ? 'Edit schedule' : `Edit ${label} trigger`,
detail:
kind === 'schedule' && t.schedule
? (t.summary ?? describeSchedule(t.schedule, t.timezone) ?? path)
: path,
onOpen: () => onEditTrigger(kind, path, script)
}
}
if (action) out.push(action)
}
return out
})
function editProducerScript(path: string) {
if (mode !== 'edit') onRequestEdit?.()
onSelect({ kind: 'runnable', runnable_kind: 'script', path })
@@ -491,8 +540,7 @@
try {
if (typeof localStorage !== 'undefined') {
if (isEmpty) localStorage.removeItem(key)
else
localStorage.setItem(key, encodeState(bundle))
else localStorage.setItem(key, encodeState(bundle))
}
} catch (e) {
console.warn('failed to mirror pipeline state', e)
@@ -593,6 +641,7 @@
selectionProducers={activeDraft ? [] : selectionProducers}
{producerScripts}
onEditScript={editProducerScript}
{producerTriggers}
{selectionColumnGraph}
{selectionColumnLoading}
{selectionColumnTruncated}
@@ -19,3 +19,14 @@ export function upstreamScriptItems(
action: () => onPick(s.path)
}))
}
/** An upstream trigger the asset pane can open, and how. */
export type UpstreamTriggerAction = { label: string; detail?: string; onOpen: () => void }
export function upstreamTriggerItems(triggers: UpstreamTriggerAction[]): Item[] {
return triggers.map((t) => ({
displayName: t.label,
description: t.detail,
action: () => t.onOpen()
}))
}