feat: pipeline node context menus, add-at-cursor, and assets-only delete

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-09-30 14:25:52 +02:00
co-authored by Claude Opus 5.5
parent 9f4e508369
commit 001fb8b5a3
16 changed files with 876 additions and 550 deletions
@@ -0,0 +1,145 @@
<!--
@component
The menu that adds a pipeline script started by a data source (a schedule, a
webhook, a queue…): the add node's, and the canvas's right-click one.
-->
<script lang="ts">
import type { Snippet } from 'svelte'
import PipelineInsertMenu, {
type PipelineInsertOptions,
type PipelineInsertPick
} from './PipelineInsertMenu.svelte'
import {
Clock,
Webhook,
Mail,
Zap,
Radio,
MessageSquare,
Database,
Send,
CloudCog,
Upload
} from 'lucide-svelte'
import type { ScriptLang } from '$lib/gen'
import type { NativeTriggerKind } from './types'
import { PIPELINE_LANGUAGES } from './pipelineLanguages'
import type { PipelineOutputKind } from './pipelineTemplates'
interface Props {
onAddPipelineScript: (
language: ScriptLang,
path: string,
source: { kind: NativeTriggerKind; path: string | undefined },
outputKind: PipelineOutputKind,
aiPrompt?: string,
options?: PipelineInsertOptions
) => void
pathPrefix: string
defaultPathSuffix: string
trigger: Snippet<[{ open: boolean }]>
/** Opens the menu each time it changes. */
openSignal?: number
}
let { onAddPipelineScript, pathPrefix, defaultPathSuffix, trigger, openSignal }: Props =
$props()
function handlePick(pick: PipelineInsertPick) {
if (!pick.language || !pick.path) return
// Native trigger annotation is marker-only — the binding lives on the
// trigger row's own `script_path`, which is created separately.
onAddPipelineScript(
pick.language as ScriptLang,
pick.path,
{ kind: pick.kindId as NativeTriggerKind, path: undefined },
(pick.outputKind ?? 'none') as PipelineOutputKind,
pick.aiPrompt,
{ schedule: pick.schedule, outputAsset: pick.outputAsset }
)
}
</script>
<PipelineInsertMenu
kinds={[
{
id: 'data_upload',
label: 'On data upload',
description: 'UI-first: run by uploading a file via the S3 picker',
icon: Upload
},
{
id: 'schedule',
label: 'On schedule',
description: 'Triggered by a schedule you create',
icon: Clock
},
{
id: 'webhook',
label: 'On webhook',
description: 'Triggered by an HTTP webhook',
icon: Webhook
},
{
id: 'email',
configuredAfterCreate: true,
label: 'On email',
description: 'Triggered by incoming email',
icon: Mail
},
{
id: 'kafka',
configuredAfterCreate: true,
label: 'On Kafka',
description: 'Triggered by a Kafka message',
icon: Zap
},
{
id: 'mqtt',
configuredAfterCreate: true,
label: 'On MQTT',
description: 'Triggered by an MQTT message',
icon: Radio
},
{
id: 'amqp',
configuredAfterCreate: true,
label: 'On AMQP',
description: 'Triggered by an AMQP (RabbitMQ) message',
icon: Radio
},
{
id: 'nats',
configuredAfterCreate: true,
label: 'On NATS',
description: 'Triggered by a NATS message',
icon: MessageSquare
},
{
id: 'postgres',
configuredAfterCreate: true,
label: 'On Postgres',
description: 'Triggered by a Postgres event',
icon: Database
},
{
id: 'sqs',
configuredAfterCreate: true,
label: 'On SQS',
description: 'Triggered by an SQS message',
icon: Send
},
{
id: 'gcp',
configuredAfterCreate: true,
label: 'On GCP Pub/Sub',
description: 'Triggered by a Pub/Sub message',
icon: CloudCog
}
]}
languages={PIPELINE_LANGUAGES as any}
{pathPrefix}
{defaultPathSuffix}
onPick={handlePick}
{openSignal}
{trigger}
/>
@@ -1,160 +1,36 @@
<script lang="ts">
import PipelineInsertMenu, { type PipelineInsertPick } from './PipelineInsertMenu.svelte'
import {
Plus,
Clock,
Webhook,
Mail,
Zap,
Radio,
MessageSquare,
Database,
Send,
CloudCog,
Upload
} from 'lucide-svelte'
import type { ScriptLang } from '$lib/gen'
import type { NativeTriggerKind } from './types'
import { PIPELINE_LANGUAGES } from './pipelineLanguages'
import AddDataSourceMenu from './AddDataSourceMenu.svelte'
import { Plus } from 'lucide-svelte'
import { NODE } from '$lib/components/graph/util'
import type { PipelineOutputKind } from './pipelineTemplates'
// Each left-column kind is just "pipeline script triggered by <trigger
// source>". id === the SCRIPT_TRIGGER_KIND value, so the handler can
// dispatch on it uniformly. Asset-triggered scripts are not in this
// menu; those live under the per-asset + inside the graph.
type KindId = NativeTriggerKind
interface Props {
data: {
onAddPipelineScript: (
language: ScriptLang,
path: string,
source: { kind: NativeTriggerKind; path: string | undefined },
outputKind: PipelineOutputKind,
aiPrompt?: string,
options?: import('./PipelineInsertMenu.svelte').PipelineInsertOptions
) => void
pathPrefix: string
defaultPathSuffix: string
}
data: Pick<
import('svelte').ComponentProps<typeof AddDataSourceMenu>,
'onAddPipelineScript' | 'pathPrefix' | 'defaultPathSuffix'
>
}
let { data }: Props = $props()
function handlePick(pick: PipelineInsertPick) {
if (!pick.language || !pick.path) return
const kindId = pick.kindId as KindId
const outputKind = (pick.outputKind ?? 'none') as PipelineOutputKind
// Native trigger annotation is marker-only — the binding lives on
// the trigger row's own `script_path`, which the user creates
// separately. Seed with `path: undefined`.
data.onAddPipelineScript(
pick.language as ScriptLang,
pick.path,
{ kind: kindId, path: undefined },
outputKind,
pick.aiPrompt,
{ schedule: pick.schedule, outputAsset: pick.outputAsset }
)
}
</script>
<!-- The layout gives this node a full node-width slot; centering the pill in
it lines it up with the nodes below whatever its label's width. -->
<div class="flex justify-center" style="width: {NODE.width}px;">
<PipelineInsertMenu
kinds={[
{
id: 'data_upload',
label: 'On data upload',
description: 'UI-first: run by uploading a file via the S3 picker',
icon: Upload
},
{
id: 'schedule',
label: 'On schedule',
description: 'Triggered by a schedule you create',
icon: Clock
},
{
id: 'webhook',
label: 'On webhook',
description: 'Triggered by an HTTP webhook',
icon: Webhook
},
{
id: 'email',
configuredAfterCreate: true,
label: 'On email',
description: 'Triggered by incoming email',
icon: Mail
},
{
id: 'kafka',
configuredAfterCreate: true,
label: 'On Kafka',
description: 'Triggered by a Kafka message',
icon: Zap
},
{
id: 'mqtt',
configuredAfterCreate: true,
label: 'On MQTT',
description: 'Triggered by an MQTT message',
icon: Radio
},
{
id: 'amqp',
configuredAfterCreate: true,
label: 'On AMQP',
description: 'Triggered by an AMQP (RabbitMQ) message',
icon: Radio
},
{
id: 'nats',
configuredAfterCreate: true,
label: 'On NATS',
description: 'Triggered by a NATS message',
icon: MessageSquare
},
{
id: 'postgres',
configuredAfterCreate: true,
label: 'On Postgres',
description: 'Triggered by a Postgres event',
icon: Database
},
{
id: 'sqs',
configuredAfterCreate: true,
label: 'On SQS',
description: 'Triggered by an SQS message',
icon: Send
},
{
id: 'gcp',
configuredAfterCreate: true,
label: 'On GCP Pub/Sub',
description: 'Triggered by a Pub/Sub message',
icon: CloudCog
}
]}
languages={PIPELINE_LANGUAGES as any}
pathPrefix={data.pathPrefix}
defaultPathSuffix={data.defaultPathSuffix}
onPick={handlePick}
>
{#snippet trigger()}
<!-- Quiet insert affordance, mirroring the flow editor's inline +
buttons (bg-surface + gray border + secondary text) — a filled
accent pill would outweigh every real node on the canvas. -->
<button
type="button"
class="h-8 px-3 rounded-full flex items-center gap-1.5 whitespace-nowrap bg-surface border border-gray-400 dark:border-gray-600 text-xs font-normal text-secondary shadow-sm hover:bg-surface-hover transition-colors cursor-pointer"
>
<Plus size={16} class="shrink-0" />
Add data source
</button>
{/snippet}
</PipelineInsertMenu>
<AddDataSourceMenu
onAddPipelineScript={data.onAddPipelineScript}
pathPrefix={data.pathPrefix}
defaultPathSuffix={data.defaultPathSuffix}
>
{#snippet trigger()}
<!-- Quiet insert affordance, mirroring the flow editor's inline +
buttons (bg-surface + gray border + secondary text) — a filled
accent pill would outweigh every real node on the canvas. -->
<button
type="button"
class="h-8 px-3 rounded-full flex items-center gap-1.5 whitespace-nowrap bg-surface border border-gray-400 dark:border-gray-600 text-xs font-normal text-secondary shadow-sm hover:bg-surface-hover transition-colors cursor-pointer"
>
<Plus size={16} class="shrink-0" />
Add data source
</button>
{/snippet}
</AddDataSourceMenu>
</div>
@@ -14,17 +14,30 @@
import RunnableNode from './RunnableNode.svelte'
import TriggerNode, { type TriggerNodeKind } from './TriggerNode.svelte'
import AddNode from './AddNode.svelte'
import AddDataSourceMenu from './AddDataSourceMenu.svelte'
import DataTestNode from './DataTestNode.svelte'
import AssetGraphEdge from './AssetGraphEdge.svelte'
import PanToNode from './PanToNode.svelte'
import InitialFitView from './InitialFitView.svelte'
import { layoutAssetGraph, PIPELINE_NODE_EXTRA_ROW } from './assetGraphLayout'
import { assetsOnlyView, type AssetUpstream } from './assetsOnlyView'
import {
assetsOnlyView,
upstreamDeletion,
type AssetUpstream,
type AssetUpstreamDelete
} from './assetsOnlyView'
import { assetsOnlyNodeWidth } from './assetNodeWidth'
import { formatAssetKind, formatShortAssetPath } from '$lib/components/assets/lib'
import { TRIGGER_NODE_STYLE } from './TriggerNode.svelte'
import { describeSchedule } from '$lib/utils/describeCron'
import Toggle from '$lib/components/Toggle.svelte'
import { fly } from 'svelte/transition'
import { Plus } from 'lucide-svelte'
import {
getContextMenuContainerClass,
CONTEXT_MENU_ITEM_BASE_CLASS,
CONTEXT_MENU_ITEM_HOVER_CLASS
} from '$lib/components/common/contextmenu/contextMenuStyles'
import { computeMutedReadKeys, dbtAssociations } from './resolveGraph'
import { buildDownstreamMap } from './graphTraversal'
import { buildLineageDownstreamMap } from './boundedCascade'
@@ -200,6 +213,9 @@
/** Offer the "Assets only" switch, which folds scripts and triggers into the
* assets they produce. */
assetsOnlyToggle?: boolean
/** Deletes what produces an asset in the assets-only view: its script, and its
* trigger when set. The canvas only offers what no other asset shares. */
onDeleteAssetUpstream?: (target: AssetUpstreamDelete) => void
}
let {
graph,
@@ -233,11 +249,30 @@
recomputedAssetIds,
assetRunStatus,
scrollZoom = true,
assetsOnlyToggle = false
assetsOnlyToggle = false,
onDeleteAssetUpstream
}: Props = $props()
let assetsOnly = $state(false)
// Right-click on the empty canvas: a menu at the pointer whose entry opens the
// add-data-source menu there too.
let paneMenu = $state<{ x: number; y: number } | undefined>(undefined)
let addMenuAt = $state<{ x: number; y: number }>({ x: 0, y: 0 })
let addMenuSignal = $state(0)
function handleContextMenu(e: MouseEvent) {
const t = e.target as HTMLElement | null
// A node's own actions menu opens on right-click; the browser's never does,
// on nodes without one either.
if (t?.closest('.svelte-flow__node')) {
e.preventDefault()
return
}
if (!onAddPipelineScript || boundPick || !t?.closest('.svelte-flow__pane')) return
e.preventDefault()
paneMenu = { x: e.clientX, y: e.clientY }
}
// `${kind}:${path}` ids for the hovered / pinned runs (both script and flow
// variants, since the run row's kind isn't known here).
const runIds = (paths: string[] | undefined): Set<string> =>
@@ -859,6 +894,7 @@
timezone: info.timezone,
summary: info.summary,
runnable_path: info.runnable_path,
runnable_paths: info.runnable_paths,
// data_upload nodes go green once a file is staged for their
// target script (see readyDataUploadPaths / page dataUploadArgs).
ready: info.runnable_path
@@ -994,6 +1030,10 @@
path: r.path
})
}
const toDelete =
onDeleteAssetUpstream && u && !u.multiple && r
? upstreamDeletion(u, r, v.upstream)
: undefined
const asset = { kind: n.data.asset_kind, path: n.data.path }
// Chips this estimate does not model keep the regular width.
const width =
@@ -1015,7 +1055,20 @@
scriptChip: 'runnableId' in upstream
})
: undefined
return { ...n, data: { ...n.data, upstream, width } }
return {
...n,
data: {
...n.data,
upstream,
width,
...(toDelete
? {
deleteCount: toDelete.trigger ? 2 : 1,
onDeleteUpstream: () => onDeleteAssetUpstream?.(toDelete)
}
: {})
}
}
})
const edges: BuiltEdge[] = v.edges.map((e) => ({ ...e, kind: e.kind as BuiltEdge['kind'] }))
if (nodes.some((n) => n.id === ADD_NODE_ID)) {
@@ -1507,7 +1560,12 @@
</script>
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div class="w-full h-full relative" bind:clientWidth={paneWidth} onclickcapture={handleBackgroundClick}>
<div
class="w-full h-full relative"
bind:clientWidth={paneWidth}
onclickcapture={handleBackgroundClick}
oncontextmenu={handleContextMenu}
>
<SvelteFlow
{nodes}
{edges}
@@ -1570,6 +1628,55 @@
</SvelteFlow>
</div>
<svelte:window
onpointerdown={(e) => {
if (paneMenu && !(e.target as HTMLElement | null)?.closest('[data-pane-menu]'))
paneMenu = undefined
}}
onkeydown={(e) => {
if (e.key === 'Escape') paneMenu = undefined
}}
/>
{#if paneMenu}
<div
class="fixed {getContextMenuContainerClass()}"
style="left: {paneMenu.x}px; top: {paneMenu.y}px;"
transition:fly={{ duration: 150, y: -10 }}
role="menu"
data-pane-menu
>
<button
type="button"
role="menuitem"
class="{CONTEXT_MENU_ITEM_BASE_CLASS} {CONTEXT_MENU_ITEM_HOVER_CLASS} gap-2 w-full text-left"
onclick={() => {
if (paneMenu) addMenuAt = paneMenu
paneMenu = undefined
addMenuSignal++
}}
>
<Plus size={14} />
Add data source
</button>
</div>
{/if}
{#if onAddPipelineScript}
<div class="fixed w-0 h-0" style="left: {addMenuAt.x}px; top: {addMenuAt.y}px;">
<AddDataSourceMenu
{onAddPipelineScript}
{pathPrefix}
{defaultPathSuffix}
openSignal={addMenuSignal}
>
{#snippet trigger()}
<span class="block w-0 h-0" aria-hidden="true"></span>
{/snippet}
</AddDataSourceMenu>
</div>
{/if}
<style lang="postcss">
:global(.svelte-flow__handle) {
opacity: 0;
@@ -4,6 +4,7 @@
import AssetGenericIcon from '$lib/components/icons/AssetGenericIcon.svelte'
import { formatAssetKind, formatShortAssetPath, type AssetKind } from '$lib/components/assets/lib'
import PipelineNodeCard from './PipelineNodeCard.svelte'
import NodeActionsMenu from './NodeActionsMenu.svelte'
import PipelineInsertMenu, { type PipelineInsertPick } from './PipelineInsertMenu.svelte'
import {
ArrowUpRight,
@@ -16,11 +17,12 @@
ShieldCheck,
ShieldAlert,
CheckCircle2,
Trash2,
XCircle
} from 'lucide-svelte'
import type { ScriptLang } from '$lib/gen'
import { enterpriseLicense } from '$lib/stores'
import { sendUserToast } from '$lib/utils'
import { sendUserToast, type Item } from '$lib/utils'
import { PIPELINE_LANGUAGES } from './pipelineLanguages'
import type { PipelineOutputKind } from './pipelineTemplates'
import type { DbtAssetProvenance } from './types'
@@ -134,6 +136,10 @@
upstream?: AssetUpstreamChips
/** Card width in the assets-only view, sized to its text. */
width?: number
/** Assets-only view: deletes the producing script and trigger no other
* asset shares; `deleteCount` is how many that is. */
onDeleteUpstream?: () => void
deleteCount?: number
}
// SvelteFlow injects this on the node component when the user clicks
// the node. Combined with our own `hovered` state to drive the
@@ -189,6 +195,18 @@
}
let showAdd = $derived(data.onAddScript != undefined)
let menuItems: Item[] = $derived(
data.onDeleteUpstream
? [
{
displayName: `Delete ${data.deleteCount} item${data.deleteCount === 1 ? '' : 's'}`,
icon: Trash2,
type: 'delete' as const,
action: () => data.onDeleteUpstream?.()
}
]
: []
)
let upstream = $derived(data.upstream)
const CHIP_SELECTED =
'bg-surface-accent-selected border-border-selected hover:bg-surface-accent-selected text-accent'
@@ -318,156 +336,158 @@
<div class="wm-recompute-flash pointer-events-none absolute inset-0 z-10 rounded-md"></div>
{/key}
{/if}
<PipelineNodeCard
kindLabel={formatAssetKind(asset)}
title={formatShortAssetPath(asset)}
tooltip={data.error ? `${data.path}: ${data.error}` : data.path}
{selected}
tone={data.error ? 'error' : undefined}
subtitle={upstream ? upstreamRow : undefined}
width={data.width}
surface={upstream ? 'primary' : 'secondary'}
>
{#snippet icon()}
<!-- Data identity carries the accent (luminance blue), pairing with
the blue write edges that produce these assets — scripts stay
neutral, so script vs data reads at a glance. -->
<AssetGenericIcon
assetKind={data.asset_kind}
class={data.error
? 'text-red-600 dark:text-red-200'
: selected
? 'text-accent'
: 'text-blue-600 dark:text-blue-400'}
size="14px"
/>
{/snippet}
{#snippet leading()}
{#if data.runStatus}
<!-- The run in view, per relation: a spinner while its producer is
building it, then its outcome. Left of the name so the eye finds
the moving nodes first on a wide graph. -->
<span
class="shrink-0 mr-1 {data.runStatus === 'failed'
? 'text-red-600 dark:text-red-400'
: data.runStatus === 'materialized'
? 'text-green-600 dark:text-green-400'
: 'text-blue-600 dark:text-blue-400'}"
title={data.runStatus}
>
{#if data.runStatus === 'running'}
<Loader2 size={11} class="animate-spin" />
{:else if data.runStatus === 'failed'}
<XCircle size={11} />
{:else}
<CheckCircle2 size={11} />
{/if}
</span>
<!-- Rows the run wrote. Recorded per relation already, and the cheapest
answer to "did this model actually produce anything" — a model that
built green but emitted 0 rows is the failure that looks like a
success. Only once settled: mid-build the number is not yet real. -->
{#if data.runStatus === 'materialized' && data.runRowCount != undefined}
<NodeActionsMenu items={menuItems} hover={hovered}>
<PipelineNodeCard
kindLabel={formatAssetKind(asset)}
title={formatShortAssetPath(asset)}
tooltip={data.error ? `${data.path}: ${data.error}` : data.path}
{selected}
tone={data.error ? 'error' : undefined}
subtitle={upstream ? upstreamRow : undefined}
width={data.width}
surface={upstream ? 'primary' : 'secondary'}
>
{#snippet icon()}
<!-- Data identity carries the accent (luminance blue), pairing with
the blue write edges that produce these assets — scripts stay
neutral, so script vs data reads at a glance. -->
<AssetGenericIcon
assetKind={data.asset_kind}
class={data.error
? 'text-red-600 dark:text-red-200'
: selected
? 'text-accent'
: 'text-blue-600 dark:text-blue-400'}
size="14px"
/>
{/snippet}
{#snippet leading()}
{#if data.runStatus}
<!-- The run in view, per relation: a spinner while its producer is
building it, then its outcome. Left of the name so the eye finds
the moving nodes first on a wide graph. -->
<span
class="shrink-0 mr-1 text-3xs tabular-nums text-tertiary"
title="{data.runRowCount} rows written by this run"
class="shrink-0 mr-1 {data.runStatus === 'failed'
? 'text-red-600 dark:text-red-400'
: data.runStatus === 'materialized'
? 'text-green-600 dark:text-green-400'
: 'text-blue-600 dark:text-blue-400'}"
title={data.runStatus}
>
{Intl.NumberFormat().format(data.runRowCount)}
{#if data.runStatus === 'running'}
<Loader2 size={11} class="animate-spin" />
{:else if data.runStatus === 'failed'}
<XCircle size={11} />
{:else}
<CheckCircle2 size={11} />
{/if}
</span>
<!-- Rows the run wrote. Recorded per relation already, and the cheapest
answer to "did this model actually produce anything" — a model that
built green but emitted 0 rows is the failure that looks like a
success. Only once settled: mid-build the number is not yet real. -->
{#if data.runStatus === 'materialized' && data.runRowCount != undefined}
<span
class="shrink-0 mr-1 text-3xs tabular-nums text-tertiary"
title="{data.runRowCount} rows written by this run"
>
{Intl.NumberFormat().format(data.runRowCount)}
</span>
{/if}
{/if}
{/snippet}
{#snippet trailing()}
<!-- Fork data-environment chip: in a fork every asset shares its parent's
name, so the env it resolves to must read at a glance. Labeled + tinted
(amber "parent" = deferred read of the parent's current table via a
view; emerald "fork" = the fork's own materialized copy) rather than a
bare icon, which was too easy to miss. The title carries the detail. -->
{#if data.fork_materialization === 'deferred'}
<span
class="shrink-0 mr-1.5 flex items-center gap-0.5 rounded px-1 py-px text-3xs font-semibold uppercase tracking-wide bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300 border border-amber-300 dark:border-amber-700"
title="Deferred to parent workspace: reads the parent's current data. Materialize it in this fork to iterate on it."
>
<ArrowUpRight size={10} />
parent
</span>
{:else if data.fork_materialization === 'fork'}
<span
class="shrink-0 mr-1.5 flex items-center gap-0.5 rounded px-1 py-px text-3xs font-semibold uppercase tracking-wide bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-300 border border-emerald-300 dark:border-emerald-700"
title="Materialized in this fork: reads and writes use the fork's isolated copy."
>
<GitFork size={10} />
fork
</span>
{/if}
{/if}
{/snippet}
{#snippet trailing()}
<!-- Fork data-environment chip: in a fork every asset shares its parent's
name, so the env it resolves to must read at a glance. Labeled + tinted
(amber "parent" = deferred read of the parent's current table via a
view; emerald "fork" = the fork's own materialized copy) rather than a
bare icon, which was too easy to miss. The title carries the detail. -->
{#if data.fork_materialization === 'deferred'}
<span
class="shrink-0 mr-1.5 flex items-center gap-0.5 rounded px-1 py-px text-3xs font-semibold uppercase tracking-wide bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300 border border-amber-300 dark:border-amber-700"
title="Deferred to parent workspace: reads the parent's current data. Materialize it in this fork to iterate on it."
>
<ArrowUpRight size={10} />
parent
</span>
{:else if data.fork_materialization === 'fork'}
<span
class="shrink-0 mr-1.5 flex items-center gap-0.5 rounded px-1 py-px text-3xs font-semibold uppercase tracking-wide bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-300 border border-emerald-300 dark:border-emerald-700"
title="Materialized in this fork: reads and writes use the fork's isolated copy."
>
<GitFork size={10} />
fork
</span>
{/if}
<!-- dbt chip: names the materialization dbt declares, plus a test count
when the model carries generic tests. Orange keeps it visually
separate from the fork/SCD2 chips, which describe Windmill state.
Where the project node is on the graph it also stands in for the edge
to it: hovering lights that node up, clicking selects it. Where it is
not — the run page, and a pipeline holding a dbt project — there is
nothing to point at, so the chip renders inert. -->
{#if data.dbt}
<button
type="button"
class="shrink-0 mr-1.5 flex items-center gap-0.5 rounded px-1 py-px text-3xs font-semibold tracking-wide bg-orange-100 dark:bg-orange-900/40 text-orange-700 dark:text-orange-300 border border-orange-300 dark:border-orange-700 {data.onDbtSelect
? 'hover:brightness-95 cursor-pointer'
: 'cursor-default'}"
title={dbtTitle}
onmouseenter={() => data.onDbtHover?.(true)}
onmouseleave={() => data.onDbtHover?.(false)}
onclick={(e) => {
e.stopPropagation()
data.onDbtSelect?.()
}}
>
<DbtIcon width={9} height={9} />
{dbtLabel}
{#if data.dbt.data_tests?.length}
<span class="opacity-70">&middot; {data.dbt.data_tests.length}T</span>
{/if}
</button>
{/if}
<!-- SCD2 companion marker: this node is the `<dim>_current` "latest row
per key" view its producer maintains alongside the base dimension.
Icon-only (the pill already truncates); the title names the base. -->
{#if data.derived_from}
<span
class="shrink-0 mr-1.5 text-violet-600 dark:text-violet-400"
title={`SCD2 current view of ${data.derived_from}: latest row per key, maintained by the same producer as the base dimension.`}
>
<History size={12} />
</span>
{/if}
{#if producer}
{#if producer.runState}
<RunStateChip runState={producer.runState} class="mr-1 h-5 px-1.5 rounded-md" />
<!-- dbt chip: names the materialization dbt declares, plus a test count
when the model carries generic tests. Orange keeps it visually
separate from the fork/SCD2 chips, which describe Windmill state.
Where the project node is on the graph it also stands in for the edge
to it: hovering lights that node up, clicking selects it. Where it is
not — the run page, and a pipeline holding a dbt project — there is
nothing to point at, so the chip renders inert. -->
{#if data.dbt}
<button
type="button"
class="shrink-0 mr-1.5 flex items-center gap-0.5 rounded px-1 py-px text-3xs font-semibold tracking-wide bg-orange-100 dark:bg-orange-900/40 text-orange-700 dark:text-orange-300 border border-orange-300 dark:border-orange-700 {data.onDbtSelect
? 'hover:brightness-95 cursor-pointer'
: 'cursor-default'}"
title={dbtTitle}
onmouseenter={() => data.onDbtHover?.(true)}
onmouseleave={() => data.onDbtHover?.(false)}
onclick={(e) => {
e.stopPropagation()
data.onDbtSelect?.()
}}
>
<DbtIcon width={9} height={9} />
{dbtLabel}
{#if data.dbt.data_tests?.length}
<span class="opacity-70">&middot; {data.dbt.data_tests.length}T</span>
{/if}
</button>
{/if}
<button
type="button"
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()
}}
>
{#if producer.language}
<LanguageIcon lang={producer.language} width={12} height={12} />
{:else}
<Code2 size={12} />
<!-- SCD2 companion marker: this node is the `<dim>_current` "latest row
per key" view its producer maintains alongside the base dimension.
Icon-only (the pill already truncates); the title names the base. -->
{#if data.derived_from}
<span
class="shrink-0 mr-1.5 text-violet-600 dark:text-violet-400"
title={`SCD2 current view of ${data.derived_from}: latest row per key, maintained by the same producer as the base dimension.`}
>
<History size={12} />
</span>
{/if}
{#if producer}
{#if producer.runState}
<RunStateChip runState={producer.runState} class="mr-1 h-5 px-1.5 rounded-md" />
{/if}
</button>
{/if}
{/snippet}
</PipelineNodeCard>
<button
type="button"
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()
}}
>
{#if producer.language}
<LanguageIcon lang={producer.language} width={12} height={12} />
{:else}
<Code2 size={12} />
{/if}
</button>
{/if}
{/snippet}
</PipelineNodeCard>
</NodeActionsMenu>
{#if showGuardBadge}
<!-- Data-test outcome badge. Floats off the TOP-RIGHT corner (opposite the
left-edge run button and the bottom + inserter) so it never collides
@@ -0,0 +1,62 @@
<!--
@component
A pipeline node's actions, reachable two ways: the kebab revealed on hover at the
node's top-right corner, and a right-click menu over the node carrying the same
items. Place it inside the node's `relative` wrapper, around the node's card.
-->
<script lang="ts">
import type { Snippet } from 'svelte'
import { twMerge } from 'tailwind-merge'
import { EllipsisVertical } from 'lucide-svelte'
import { stopPropagation, preventDefault } from 'svelte/legacy'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import ContextMenu from '$lib/components/common/contextmenu/ContextMenu.svelte'
import { contextMenuItemsFromMenu } from '$lib/components/common/contextmenu/fromMenuItems'
import type { Item } from '$lib/utils'
interface Props {
items: Item[]
/** The pointer is over the node: shows the kebab. */
hover: boolean
children: Snippet
}
let { items, hover, children }: Props = $props()
let menuOpen = $state(false)
let contextItems = $derived(contextMenuItemsFromMenu(items))
</script>
{#if items.length > 0}
<ContextMenu items={contextItems}>
{@render children()}
</ContextMenu>
<!-- Rendered only on hover or while open, so the canvas stays clean at rest.
`pointerdown` is stopped so svelte-flow doesn't select the node when the
user reaches for the menu. -->
<div class="absolute -top-2 -right-2 h-7 p-1 min-w-7" style="will-change: transform;">
<DropdownV2
{items}
placement="bottom-end"
bind:open={menuOpen}
fixedHeight={false}
usePointerDownOutside
>
{#snippet buttonReplacement()}
<button
class={twMerge(
'center-center p-1 text-secondary shadow-sm bg-surface duration-0 hover:bg-surface-tertiary',
hover || menuOpen ? 'block' : '!hidden',
'shadow-md rounded-md'
)}
onpointerdown={stopPropagation(preventDefault(() => {}))}
title="Actions"
>
<EllipsisVertical size={12} />
</button>
{/snippet}
</DropdownV2>
</div>
{:else}
{@render children()}
{/if}
@@ -11,6 +11,7 @@
import type { ColumnLineageGraph } from './columnLineageGraph'
import HideButton from '$lib/components/apps/editor/settingsPanel/HideButton.svelte'
import AssetGraphCanvas from './AssetGraphCanvas.svelte'
import type { AssetUpstreamDelete } from './assetsOnlyView'
import AssetGraphDetailsPane from './AssetGraphDetailsPane.svelte'
import PipelineEventLog from './PipelineEventLog.svelte'
import type {
@@ -67,6 +68,7 @@
onAddScriptForAsset,
onAddPipelineScript,
onRunnableMenuRemove,
onDeleteAssetUpstream,
onRunProducer,
idlePane,
onRequestEdit,
@@ -167,6 +169,7 @@
options?: import('./PipelineInsertMenu.svelte').PipelineInsertOptions
) => void
onRunnableMenuRemove?: (...args: any[]) => void
onDeleteAssetUpstream?: (target: AssetUpstreamDelete) => void
onRunProducer?: (producer: RunProducer) => Promise<string | undefined>
idlePane?: Snippet
onRequestEdit?: () => void
@@ -518,6 +521,7 @@
{onAddScriptForAsset}
{onAddPipelineScript}
{onRunnableMenuRemove}
{onDeleteAssetUpstream}
{onRunProducer}
{validStartPaths}
{onStartBoundedRun}
@@ -85,6 +85,8 @@
/** Receives whether the menu is open, e.g. to keep a hover state while it is. */
trigger: import('svelte').Snippet<[{ open: boolean }]>
placement?: 'bottom' | 'top' | 'left' | 'right'
/** Opens the menu each time it changes, for an entry point other than the trigger. */
openSignal?: number
}
let {
@@ -94,7 +96,8 @@
trigger: triggerSnippet,
placement = 'bottom',
defaultPathSuffix,
onPick
onPick,
openSignal
}: Props = $props()
// When there's only one trigger kind, hide the Trigger column entirely
@@ -110,6 +113,9 @@
})
let selected = $state(buildEmptySelected())
let menuOpen = $state(false)
$effect(() => {
if (openSignal) menuOpen = true
})
let selectedKind = $derived(kinds.find((k) => k.id === selected.triggerId))
// Steps after the first, each configuring something the picks call for.
@@ -3,18 +3,8 @@
import { workspaceStore } from '$lib/stores'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import type { NativeTriggerKind, PipelineTriggerDraftKind } from './types'
import {
EmailTriggerService,
GcpTriggerService,
KafkaTriggerService,
MqttTriggerService,
AmqpTriggerService,
NatsTriggerService,
PostgresTriggerService,
ScheduleService,
SqsTriggerService,
type Schedule
} from '$lib/gen'
import type { Schedule } from '$lib/gen'
import { deleteTriggerRow } from './pipelineTriggerDraftDeploy'
import KafkaTriggerEditor from '$lib/components/triggers/kafka/KafkaTriggerEditor.svelte'
import MqttTriggerEditor from '$lib/components/triggers/mqtt/MqttTriggerEditor.svelte'
import AmqpTriggerEditor from '$lib/components/triggers/amqp/AmqpTriggerEditor.svelte'
@@ -189,37 +179,7 @@
const { kind, path: triggerPath } = triggerDeleteTarget
triggerDeleteLoading = true
try {
switch (kind) {
case 'schedule':
await ScheduleService.deleteSchedule({ workspace, path: triggerPath })
break
case 'kafka':
await KafkaTriggerService.deleteKafkaTrigger({ workspace, path: triggerPath })
break
case 'mqtt':
await MqttTriggerService.deleteMqttTrigger({ workspace, path: triggerPath })
break
case 'amqp':
await AmqpTriggerService.deleteAmqpTrigger({ workspace, path: triggerPath })
break
case 'nats':
await NatsTriggerService.deleteNatsTrigger({ workspace, path: triggerPath })
break
case 'postgres':
await PostgresTriggerService.deletePostgresTrigger({ workspace, path: triggerPath })
break
case 'sqs':
await SqsTriggerService.deleteSqsTrigger({ workspace, path: triggerPath })
break
case 'gcp':
await GcpTriggerService.deleteGcpTrigger({ workspace, path: triggerPath })
break
case 'email':
await EmailTriggerService.deleteEmailTrigger({ workspace, path: triggerPath })
break
default:
return
}
if (!(await deleteTriggerRow(kind, triggerPath, workspace))) return
sendUserToast(`Deleted ${kind} trigger "${triggerPath}"`)
triggerDeleteTarget = undefined
onUpdate()
@@ -3,7 +3,6 @@
import {
ChevronDown,
Code2,
EllipsisVertical,
GitBranch,
Layers,
Loader2,
@@ -18,15 +17,14 @@
} from 'lucide-svelte'
import DbtIcon from '$lib/components/icons/DbtIcon.svelte'
import { twMerge } from 'tailwind-merge'
import { preventDefault, stopPropagation } from 'svelte/legacy'
import type { GraphUsageKind } from './types'
import type { RunnableRunState } from './activeRunnables.svelte'
import { parseDurationSecs } from './parsePipelineAnnotations'
import LanguageIcon from '$lib/components/common/languageIcons/LanguageIcon.svelte'
import PipelineNodeCard from './PipelineNodeCard.svelte'
import NodeActionsMenu from './NodeActionsMenu.svelte'
import RunStateChip from './RunStateChip.svelte'
import type { ScriptLang } from '$lib/gen'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import type { Item } from '$lib/utils'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
@@ -120,7 +118,6 @@
)
let hover = $state(false)
let menuOpen = $state(false)
let running = $state(false)
// Amber "computing now" surface, gated so only the replay player lights it up.
@@ -223,101 +220,103 @@
selected state, dashes for unsaved drafts — 1px, like the trigger
nodes' dashed outline, so every draft node reads the same.
-->
<PipelineNodeCard
surface="primary"
kindLabel="{kindLabel}{data.unsaved ? ' · draft' : ''}"
title={data.summary || data.path}
tooltip={data.error ? `${data.path}: ${data.error}` : nodeTooltip}
{selected}
draft={data.unsaved}
tone={computingNow ? 'running' : data.error ? 'error' : undefined}
>
{#snippet icon()}
{#if data.language && data.runnable_kind === 'script'}
<LanguageIcon lang={data.language} width={14} height={14} />
{:else}
<Icon size={14} class={selected ? 'text-accent' : 'text-secondary'} />
{/if}
{/snippet}
{#snippet trailing()}
<!-- Annotation chips share one neutral treatment — the icon carries
the meaning (colors are reserved for feedback, per the brand
guidelines). Only the freshness chip (when it has a verdict)
and the run-state chip below use semantic colors. -->
{#if data.partition_kind}
<div
class="shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm bg-surface-secondary text-secondary"
title={`// partitioned ${data.partition_kind}`}
>
<Layers size={10} />
<span class="text-3xs leading-none">{data.partition_kind}</span>
</div>
{/if}
<!-- Freshness is the one annotation chip that carries feedback (a
fresh/stale verdict against real run history), so like the
run-state chip it uses semantic colors: emerald = within window,
amber = stale. Neutral when there's no verdict (drafts, bad
window value). -->
{#if data.freshness}
<div
class={twMerge(
'shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm',
freshnessState === 'fresh'
? 'bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-300'
: freshnessState === 'stale'
? 'bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300'
: 'bg-surface-secondary text-secondary'
)}
title={freshnessTooltip}
>
<Timer size={10} />
<span class="text-3xs leading-none">{data.freshness}</span>
</div>
{/if}
{#if data.tag}
<div
class="shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm bg-surface-secondary text-secondary"
title={`// tag ${data.tag}`}
>
<Tag size={10} />
<span class="text-3xs leading-none">{data.tag}</span>
</div>
{/if}
{#if data.retry}
{@const r = data.retry}
<div
class="shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm bg-surface-secondary text-secondary"
title={`// retry ${r.count}${r.delay ? ` ${r.delay}` : ''}`}
>
<RotateCw size={10} />
<span class="text-3xs leading-none">×{r.count}</span>
</div>
{/if}
{#if data.macros && data.macros.length > 0}
<div
class="shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm bg-surface-secondary text-secondary"
title={`// macros — defines ${data.macros.length} macro${data.macros.length > 1 ? 's' : ''}:\n${data.macros
.map((m) => `• ${m.name}(${m.params})${m.is_table ? ' → table' : ''}`)
.join('\n')}`}
>
<SquareFunction size={10} />
<span class="text-3xs leading-none">×{data.macros.length}</span>
</div>
{/if}
{#if data.dbt}
<div
class="shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm bg-orange-100 dark:bg-orange-900/40 text-orange-700 dark:text-orange-300"
title={`dbt project — materializes ${data.dbt.model_count} model${data.dbt.model_count === 1 ? '' : 's'}`}
>
<DbtIcon width={10} height={10} />
<span class="text-3xs leading-none">×{data.dbt.model_count}</span>
</div>
{/if}
{#if data.runState}
<RunStateChip runState={data.runState} class="mr-1" />
{/if}
{/snippet}
</PipelineNodeCard>
<NodeActionsMenu items={menuItems} {hover}>
<PipelineNodeCard
surface="primary"
kindLabel="{kindLabel}{data.unsaved ? ' · draft' : ''}"
title={data.summary || data.path}
tooltip={data.error ? `${data.path}: ${data.error}` : nodeTooltip}
{selected}
draft={data.unsaved}
tone={computingNow ? 'running' : data.error ? 'error' : undefined}
>
{#snippet icon()}
{#if data.language && data.runnable_kind === 'script'}
<LanguageIcon lang={data.language} width={14} height={14} />
{:else}
<Icon size={14} class={selected ? 'text-accent' : 'text-secondary'} />
{/if}
{/snippet}
{#snippet trailing()}
<!-- Annotation chips share one neutral treatment — the icon carries
the meaning (colors are reserved for feedback, per the brand
guidelines). Only the freshness chip (when it has a verdict)
and the run-state chip below use semantic colors. -->
{#if data.partition_kind}
<div
class="shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm bg-surface-secondary text-secondary"
title={`// partitioned ${data.partition_kind}`}
>
<Layers size={10} />
<span class="text-3xs leading-none">{data.partition_kind}</span>
</div>
{/if}
<!-- Freshness is the one annotation chip that carries feedback (a
fresh/stale verdict against real run history), so like the
run-state chip it uses semantic colors: emerald = within window,
amber = stale. Neutral when there's no verdict (drafts, bad
window value). -->
{#if data.freshness}
<div
class={twMerge(
'shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm',
freshnessState === 'fresh'
? 'bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-300'
: freshnessState === 'stale'
? 'bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300'
: 'bg-surface-secondary text-secondary'
)}
title={freshnessTooltip}
>
<Timer size={10} />
<span class="text-3xs leading-none">{data.freshness}</span>
</div>
{/if}
{#if data.tag}
<div
class="shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm bg-surface-secondary text-secondary"
title={`// tag ${data.tag}`}
>
<Tag size={10} />
<span class="text-3xs leading-none">{data.tag}</span>
</div>
{/if}
{#if data.retry}
{@const r = data.retry}
<div
class="shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm bg-surface-secondary text-secondary"
title={`// retry ${r.count}${r.delay ? ` ${r.delay}` : ''}`}
>
<RotateCw size={10} />
<span class="text-3xs leading-none">×{r.count}</span>
</div>
{/if}
{#if data.macros && data.macros.length > 0}
<div
class="shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm bg-surface-secondary text-secondary"
title={`// macros — defines ${data.macros.length} macro${data.macros.length > 1 ? 's' : ''}:\n${data.macros
.map((m) => `• ${m.name}(${m.params})${m.is_table ? ' → table' : ''}`)
.join('\n')}`}
>
<SquareFunction size={10} />
<span class="text-3xs leading-none">×{data.macros.length}</span>
</div>
{/if}
{#if data.dbt}
<div
class="shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm bg-orange-100 dark:bg-orange-900/40 text-orange-700 dark:text-orange-300"
title={`dbt project — materializes ${data.dbt.model_count} model${data.dbt.model_count === 1 ? '' : 's'}`}
>
<DbtIcon width={10} height={10} />
<span class="text-3xs leading-none">×{data.dbt.model_count}</span>
</div>
{/if}
{#if data.runState}
<RunStateChip runState={data.runState} class="mr-1" />
{/if}
{/snippet}
</PipelineNodeCard>
</NodeActionsMenu>
{#if showRun}
<!-- Run button revealed on hover/select. Matches the placement, size,
and behaviour of AssetNode's run button so both nodes feel
@@ -443,39 +442,6 @@
</div>
{/if}
{#if menuItems.length > 0}
<!--
Hover-revealed action menu, top-right of the node. Mirrors the
FlowModuleSchemaItem pattern: position the trigger button just
outside the node frame, only render it on hover (or while the
menu is open) so the canvas isn't visually cluttered when idle.
The pointerdown stop+preventDefault keeps xyflow from selecting
the node when the user is reaching for the menu.
-->
<div class="absolute -top-2 -right-2 h-7 p-1 min-w-7" style="will-change: transform;">
<DropdownV2
items={menuItems}
placement="bottom-end"
bind:open={menuOpen}
fixedHeight={false}
usePointerDownOutside
>
{#snippet buttonReplacement()}
<button
class={twMerge(
'center-center p-1 text-secondary shadow-sm bg-surface duration-0 hover:bg-surface-tertiary',
hover || menuOpen ? 'block' : '!hidden',
'shadow-md rounded-md'
)}
onpointerdown={stopPropagation(preventDefault(() => {}))}
title="Actions"
>
<EllipsisVertical size={12} />
</button>
{/snippet}
</DropdownV2>
</div>
{/if}
</div>
<Handle type="target" position={Position.Top} isConnectable={false} />
@@ -50,11 +50,9 @@
<script lang="ts">
import { Handle, Position } from '@xyflow/svelte'
import PipelineNodeCard from './PipelineNodeCard.svelte'
import NodeActionsMenu from './NodeActionsMenu.svelte'
import { describeSchedule } from '$lib/utils/describeCron'
import { twMerge } from 'tailwind-merge'
import { AlertTriangle, CheckCircle2, EllipsisVertical, Target, Trash2 } from 'lucide-svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import { stopPropagation, preventDefault } from 'svelte/legacy'
import { AlertTriangle, CheckCircle2, Target, Trash2 } from 'lucide-svelte'
import type { Item } from '$lib/utils'
interface Props {
@@ -123,7 +121,6 @@
let { data }: Props = $props()
let hover = $state(false)
let menuOpen = $state(false)
let style = $derived(TRIGGER_NODE_STYLE[data.kind])
// Webhooks are never genuinely "missing" — every deployed runnable has an
@@ -299,60 +296,31 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="relative" onmouseenter={() => (hover = true)} onmouseleave={() => (hover = false)}>
<PipelineNodeCard
kindLabel={card.kindLabel}
title={card.title}
tooltip={card.tooltip}
draft={card.draft}
tone={card.tone}
onclick={card.onclick}
>
{#snippet icon()}
{#if canOpenDataUpload}
<DataUploadIcon
size={14}
class={dataUploadReady ? 'text-green-600 dark:text-green-400' : style.iconText}
/>
{:else}
<Icon
size={14}
class={displayMissing || canCreate ? 'text-red-600 dark:text-red-400' : style.iconText}
/>
{/if}
{/snippet}
</PipelineNodeCard>
<NodeActionsMenu items={menuItems} {hover}>
<PipelineNodeCard
kindLabel={card.kindLabel}
title={card.title}
tooltip={card.tooltip}
draft={card.draft}
tone={card.tone}
onclick={card.onclick}
>
{#snippet icon()}
{#if canOpenDataUpload}
<DataUploadIcon
size={14}
class={dataUploadReady ? 'text-green-600 dark:text-green-400' : style.iconText}
/>
{:else}
<Icon
size={14}
class={displayMissing || canCreate ? 'text-red-600 dark:text-red-400' : style.iconText}
/>
{/if}
{/snippet}
</PipelineNodeCard>
</NodeActionsMenu>
{#if menuItems.length > 0}
<!-- Hover-revealed kebab menu (Delete only for now). Mirrors the
RunnableNode pattern: positioned just outside the top-right of
the node, rendered only on hover or while the menu is open so
the canvas stays clean at rest. `pointerdown` is stopped so
svelte-flow doesn't kick off node selection / drag when the
user reaches for the menu. -->
<div class="absolute -top-2 -right-2 h-7 p-1 min-w-7" style="will-change: transform;">
<DropdownV2
items={menuItems}
placement="bottom-end"
bind:open={menuOpen}
fixedHeight={false}
usePointerDownOutside
>
{#snippet buttonReplacement()}
<button
class={twMerge(
'center-center p-1 text-secondary shadow-sm bg-surface duration-0 hover:bg-surface-tertiary',
hover || menuOpen ? 'block' : '!hidden',
'shadow-md rounded-md'
)}
onpointerdown={stopPropagation(preventDefault(() => {}))}
title="Actions"
>
<EllipsisVertical size={12} />
</button>
{/snippet}
</DropdownV2>
</div>
{/if}
</div>
<Handle type="source" position={Position.Bottom} isConnectable={false} />
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { assetsOnlyView } from './assetsOnlyView'
import { assetsOnlyView, upstreamDeletion, type AssetUpstream } from './assetsOnlyView'
const node = (id: string, type: string, data: any = {}) => ({ id, type, data })
const edge = (source: string, target: string, kind: string) => ({
@@ -54,3 +54,39 @@ describe('assetsOnlyView', () => {
expect(v.upstream.get('asset:ducklake:report')).toEqual({ multiple: true })
})
})
describe('upstreamDeletion', () => {
const script = { runnable_kind: 'script', path: 'f/p/a' }
const schedule = (targets: string[]) => ({
kind: 'schedule',
nodeId: 'trigger:schedule:f/p/a_schedule',
data: { ref: 'f/p/a_schedule', runnable_paths: targets }
})
const own = (trigger?: any): AssetUpstream & { multiple: false } => ({
multiple: false,
runnableId: 'script:f/p/a',
trigger
})
it('deletes the script and a trigger that fires only it', () => {
const u = own(schedule(['f/p/a']))
expect(upstreamDeletion(u, script, new Map([['asset:x', u]]))).toEqual({
script: { path: 'f/p/a', unsaved: false },
trigger: { kind: 'schedule', path: 'f/p/a_schedule', draft: false }
})
})
it('keeps a trigger that fires other scripts', () => {
const u = own(schedule(['f/p/a', 'f/p/b']))
expect(upstreamDeletion(u, script, new Map([['asset:x', u]]))?.trigger).toBeUndefined()
})
it('deletes nothing when the script builds another asset too', () => {
const u = own(schedule(['f/p/a']))
const all = new Map([
['asset:x', u],
['asset:y', u]
])
expect(upstreamDeletion(u, script, all)).toBeUndefined()
})
})
@@ -1,3 +1,5 @@
import type { NativeTriggerKind } from './types'
/** A node or edge of the canvas model, reduced to what the assets-only view reads. */
export type ViewNode = { id: string; type: string; data: any }
export type ViewEdge = {
@@ -8,6 +10,12 @@ export type ViewEdge = {
unsaved?: boolean
}
/** The script and trigger an assets-only asset's delete removes. */
export type AssetUpstreamDelete = {
script: { path: string; unsaved: boolean }
trigger?: { kind: NativeTriggerKind; path: string; draft: boolean }
}
/** What an asset shows of the script and trigger that produce it, in the assets-only view. */
export type AssetUpstream =
| { multiple: true }
@@ -100,3 +108,31 @@ export function assetsOnlyView<N extends ViewNode, E extends ViewEdge>(
const nodeIds = new Set(nodes.filter((n) => n.type === 'asset').map((n) => n.id))
return { nodeIds, edges: [...out.values()], upstream }
}
/** What deleting an asset's upstream removes: the script unless another asset
* is also built by it, and with it the trigger unless that fires other scripts.
* Undefined when nothing can go. */
export function upstreamDeletion(
u: AssetUpstream & { multiple: false },
r: { runnable_kind: string; path: string; unsaved?: boolean },
all: Map<string, AssetUpstream>
): AssetUpstreamDelete | undefined {
if (r.runnable_kind !== 'script') return undefined
const producesOthers =
[...all.values()].filter((o) => !o.multiple && o.runnableId === u.runnableId).length > 1
if (producesOthers) return undefined
const t = u.trigger
const kind = t?.kind as NativeTriggerKind | undefined
const targets: string[] = t?.data?.runnable_paths ?? []
const ownTrigger =
kind &&
kind !== 'webhook' &&
kind !== 'data_upload' &&
t?.data?.ref &&
!t.data.missing &&
targets.every((p) => p === r.path)
return {
script: { path: r.path, unsaved: !!r.unsaved },
trigger: ownTrigger ? { kind, path: t.data.ref, draft: !!t.data.draft } : undefined
}
}
@@ -9,7 +9,18 @@ import { saveNatsTriggerFromCfg } from '$lib/components/triggers/nats/utils'
import { savePostgresTriggerFromCfg } from '$lib/components/triggers/postgres/utils'
import { saveSqsTriggerFromCfg } from '$lib/components/triggers/sqs/utils'
import { saveGcpTriggerFromCfg } from '$lib/components/triggers/gcp/utils'
import type { PipelineTriggerDraft } from './types'
import {
AmqpTriggerService,
EmailTriggerService,
GcpTriggerService,
KafkaTriggerService,
MqttTriggerService,
NatsTriggerService,
PostgresTriggerService,
ScheduleService,
SqsTriggerService
} from '$lib/gen'
import type { NativeTriggerKind, PipelineTriggerDraft } from './types'
/** Creates the trigger through the same helper its editor saves with; that helper
* reports the outcome as a toast and resolves to whether it succeeded. */
@@ -36,3 +47,43 @@ export function deployTriggerDraft(d: PipelineTriggerDraft, workspace: string):
return saveGcpTriggerFromCfg('', cfg, false, workspace, usedTriggerKinds)
}
}
/** Deletes a deployed trigger row. False for the kinds that have none (webhook,
* data upload). */
export async function deleteTriggerRow(
kind: NativeTriggerKind,
path: string,
workspace: string
): Promise<boolean> {
switch (kind) {
case 'schedule':
await ScheduleService.deleteSchedule({ workspace, path })
return true
case 'kafka':
await KafkaTriggerService.deleteKafkaTrigger({ workspace, path })
return true
case 'mqtt':
await MqttTriggerService.deleteMqttTrigger({ workspace, path })
return true
case 'amqp':
await AmqpTriggerService.deleteAmqpTrigger({ workspace, path })
return true
case 'nats':
await NatsTriggerService.deleteNatsTrigger({ workspace, path })
return true
case 'postgres':
await PostgresTriggerService.deletePostgresTrigger({ workspace, path })
return true
case 'sqs':
await SqsTriggerService.deleteSqsTrigger({ workspace, path })
return true
case 'gcp':
await GcpTriggerService.deleteGcpTrigger({ workspace, path })
return true
case 'email':
await EmailTriggerService.deleteEmailTrigger({ workspace, path })
return true
default:
return false
}
}
@@ -0,0 +1,18 @@
import type { Item } from '$lib/utils'
import type { ContextMenuItem } from './ContextMenu.svelte'
/** A dropdown's items as the same entries of a right-click menu. */
export function contextMenuItemsFromMenu(items: Item[]): ContextMenuItem[] {
return items.flatMap((item) => [
...(item.separatorTop ? [{ id: `${item.displayName}-divider`, label: '', divider: true }] : []),
{
id: item.displayName,
label: item.displayName,
icon: item.icon,
disabled: item.disabled,
type: item.type,
shortcut: item.shortcut,
onClick: item.action as (() => void) | undefined
}
])
}
@@ -3,6 +3,7 @@
import { Handle, Position } from '@xyflow/svelte'
import { twMerge } from 'tailwind-merge'
import ContextMenu, { type ContextMenuItem } from '../../../common/contextmenu/ContextMenu.svelte'
import { contextMenuItemsFromMenu } from '../../../common/contextmenu/fromMenuItems'
import { getGraphContext } from '../../graphContext'
import type { Item } from '$lib/utils'
@@ -28,21 +29,7 @@
}: Props = $props()
let resolvedContextMenuItems: ContextMenuItem[] | undefined = $derived(
contextMenuItems ??
menuItems?.flatMap((item) => [
...(item.separatorTop
? [{ id: `${item.displayName}-divider`, label: '', divider: true }]
: []),
{
id: item.displayName,
label: item.displayName,
icon: item.icon,
disabled: item.disabled,
type: item.type,
shortcut: item.shortcut,
onClick: item.action as (() => void) | undefined
}
])
contextMenuItems ?? (menuItems && contextMenuItemsFromMenu(menuItems))
)
// NodeWrapper is reused outside the flow graph (e.g. the app decision-tree
@@ -93,7 +93,11 @@
isDraftableTriggerKind,
triggerDraftKey
} from '$lib/components/assets/AssetGraph/pipelineTriggerDrafts'
import { deployTriggerDraft } from '$lib/components/assets/AssetGraph/pipelineTriggerDraftDeploy'
import {
deleteTriggerRow,
deployTriggerDraft
} from '$lib/components/assets/AssetGraph/pipelineTriggerDraftDeploy'
import type { AssetUpstreamDelete } from '$lib/components/assets/AssetGraph/assetsOnlyView'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import type { PipelineInsertOptions } from '$lib/components/assets/AssetGraph/PipelineInsertMenu.svelte'
import {
@@ -664,6 +668,8 @@
// are removed from the drafts map as they land.
let savingAll = $state(false)
let saveErrors = $state<Map<string, string>>(new Map())
// Opened by a save that fails, so the reason is on screen without a click.
let saveErrorsOpen = $state(false)
// Post-deploy verification: capture the cascade-relevant facts (writes +
// `// on` subscriptions) the resolved draft graph promises for `paths`.
@@ -840,6 +846,7 @@
reportDeployDrift(new Map([...predicted].filter(([p]) => savedPaths.includes(p))))
}
saveErrors = errors
if (errors.size > 0) saveErrorsOpen = true
savingAll = false
const savedCount = savedPaths.length + savedTriggers.length
if (savedCount > 0 && errors.size === 0) {
@@ -1067,6 +1074,7 @@
const nextErrors = new Map(saveErrors)
nextErrors.set(path, msg)
saveErrors = nextErrors
saveErrorsOpen = true
}
// Pick up the next queued rename, if any.
const next = state.queuedPath
@@ -1197,6 +1205,43 @@
openTriggerDraft(source.kind, scriptPath)
}
}
// Assets-only "Delete N items": one confirmation, then the trigger and the
// script, each discarded if it is a draft and deleted if it is deployed.
let assetDeleteTarget = $state<AssetUpstreamDelete | undefined>(undefined)
let assetDeleteLoading = $state(false)
async function confirmAssetUpstreamDelete() {
const target = assetDeleteTarget
const ws = $workspaceStore
if (!target || !ws) return
assetDeleteLoading = true
const { script, trigger } = target
try {
if (trigger) {
const key = triggerDraftKey(trigger.kind, trigger.path)
if (pe.triggerDrafts.has(key)) pe.discardTriggerDraft(key)
if (!trigger.draft) await deleteTriggerRow(trigger.kind, trigger.path, ws)
}
const deployed = graphRes.current?.runnables.some(
(r) => r.path === script.path && !r.unsaved
)
if (pe.drafts.has(script.path)) discardDraft(script.path)
if (deployed) {
await ScriptService.deleteScriptByPath({ workspace: ws, path: script.path })
pe.discardTriggerDraftsFor(script.path)
forgetPath(script.path)
forgetScriptCache(script.path)
}
const n = trigger ? 2 : 1
sendUserToast(`Deleted ${n} item${n === 1 ? '' : 's'}`)
assetDeleteTarget = undefined
await graphRes.refetch()
} catch (e: any) {
sendUserToast(`Could not delete: ${e?.body ?? e?.message ?? String(e)}`, true)
} finally {
assetDeleteLoading = false
}
}
function handleRunnableMenuRemove(info: {
runnable_kind: 'script' | 'flow'
path: string
@@ -2751,7 +2796,13 @@
the editor context. Drafts that succeed disappear from
the map; the ones still listed here are the unresolved
failures. -->
<Popover placement="bottom-end" contentClasses="p-3 max-w-[480px]" usePointerDownOutside>
<Popover
placement="bottom-end"
contentClasses="p-3 max-w-[480px]"
usePointerDownOutside
enableFlyTransition
bind:isOpen={saveErrorsOpen}
>
{#snippet trigger()}
<button
type="button"
@@ -2867,6 +2918,7 @@
onAddScriptForAsset={mode === 'edit' ? handleAddScriptForAsset : undefined}
onAddPipelineScript={mode === 'edit' ? handleAddPipelineScript : undefined}
onRunnableMenuRemove={mode === 'edit' ? handleRunnableMenuRemove : undefined}
onDeleteAssetUpstream={mode === 'edit' ? (t) => (assetDeleteTarget = t) : undefined}
onRunProducer={mode === 'edit' ? handleRunProducer : undefined}
onRequestEdit={isOperator ? undefined : () => setMode('edit')}
canRunByPath={openScriptHasDataUpload}
@@ -2996,6 +3048,38 @@
<!-- Native trigger drawer wiring: create/edit/delete drawers (edit-mode
only) + the always-mounted webhook drawer. Driven imperatively from the
page via `triggerEditors`. -->
<ConfirmationModal
open={assetDeleteTarget != undefined}
loading={assetDeleteLoading}
title={assetDeleteTarget?.trigger ? 'Delete 2 items?' : 'Delete 1 item?'}
confirmationText="Delete"
onConfirmed={confirmAssetUpstreamDelete}
onCanceled={() => {
if (!assetDeleteLoading) assetDeleteTarget = undefined
}}
>
{#if assetDeleteTarget}
{@const t = assetDeleteTarget}
<div class="flex flex-col gap-2 text-xs text-secondary">
<p>These are deleted, along with every version. The tables they wrote stay.</p>
<ul class="flex flex-col gap-1">
<li class="flex flex-col">
<span class="text-emphasis truncate">{t.script.path}</span>
<span class="text-2xs text-hint">Script{t.script.unsaved ? ' · draft' : ''}</span>
</li>
{#if t.trigger}
<li class="flex flex-col">
<span class="text-emphasis truncate">{t.trigger.path}</span>
<span class="text-2xs text-hint"
>{t.trigger.kind} trigger{t.trigger.draft ? ' · draft' : ''}</span
>
</li>
{/if}
</ul>
</div>
{/if}
</ConfirmationModal>
<ConfirmationModal
open={confirmTriggerDeployOpen}
title="Deploy triggers?"