From f80d2594b11e8faa3b203469466fe0d3fbc90bcf Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Wed, 30 Sep 2026 18:27:23 +0200 Subject: [PATCH] feat: drag an asset's + onto a script or the canvas; tell reactive assets-only edges apart Co-Authored-By: Claude Opus 5.5 (1M context) --- .../AssetGraph/AddDownstreamMenu.svelte | 73 ++++++ .../AssetGraph/AddDownstreamPill.svelte | 23 ++ .../assets/AssetGraph/AssetGraphCanvas.svelte | 214 +++++++++++++++--- .../assets/AssetGraph/AssetNode.svelte | 65 ++---- .../assets/AssetGraph/assetsOnlyView.ts | 11 + .../assets/AssetGraph/edgeDescription.test.ts | 2 +- .../assets/AssetGraph/edgeDescription.ts | 8 +- 7 files changed, 326 insertions(+), 70 deletions(-) create mode 100644 frontend/src/lib/components/assets/AssetGraph/AddDownstreamMenu.svelte create mode 100644 frontend/src/lib/components/assets/AssetGraph/AddDownstreamPill.svelte diff --git a/frontend/src/lib/components/assets/AssetGraph/AddDownstreamMenu.svelte b/frontend/src/lib/components/assets/AssetGraph/AddDownstreamMenu.svelte new file mode 100644 index 0000000000..2a54fe11d4 --- /dev/null +++ b/frontend/src/lib/components/assets/AssetGraph/AddDownstreamMenu.svelte @@ -0,0 +1,73 @@ + + + + + + diff --git a/frontend/src/lib/components/assets/AssetGraph/AddDownstreamPill.svelte b/frontend/src/lib/components/assets/AssetGraph/AddDownstreamPill.svelte new file mode 100644 index 0000000000..8ebd24455c --- /dev/null +++ b/frontend/src/lib/components/assets/AssetGraph/AddDownstreamPill.svelte @@ -0,0 +1,23 @@ + + + + + + + Add downstream step + + diff --git a/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte b/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte index aef3469414..007813d41e 100644 --- a/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte @@ -16,6 +16,8 @@ import AddNode from './AddNode.svelte' import AddDataSourceMenu from './AddDataSourceMenu.svelte' import PipelineInsertMenu, { type PipelineInsertSchedule } from './PipelineInsertMenu.svelte' + import AddDownstreamMenu from './AddDownstreamMenu.svelte' + import AddDownstreamPill from './AddDownstreamPill.svelte' import DataTestNode from './DataTestNode.svelte' import AssetGraphEdge from './AssetGraphEdge.svelte' import PanToNode from './PanToNode.svelte' @@ -291,55 +293,170 @@ onAddAssetTrigger }: Props = $props() - // Dragging a script's top dot onto an asset subscribes the script to it. Only - // the assets it can subscribe to without a loop stay lit while dragging. + // Two drags write `// on`: a script's top dot onto an asset, and an asset's "+" + // onto a script. Only the nodes the drop can subscribe without a loop stay lit. + // The "+" can also be dropped on the blank canvas, opening its add menu there. let onDrag = $state< | { sourceId: string - scripts: string[] from: { x: number; y: number } to: { x: number; y: number } eligible: Set over: string | undefined + /** Dragging an asset's "+", whose blank-canvas drop adds a step. */ + feed: boolean + blank: boolean } | undefined >(undefined) + // Hit-tested on the boxes rather than with elementFromPoint: svelte-flow can + // make nodes click-through while the pointer is held down. + const within = (el: Element, x: number, y: number) => { + const r = el.getBoundingClientRect() + return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom + } + let flowEl: HTMLElement | undefined = $state() const nodeUnder = (x: number, y: number) => - document.elementFromPoint(x, y)?.closest('.svelte-flow__node')?.getAttribute('data-id') ?? - undefined - function startOnDrag(sourceId: string, scripts: string[], e: PointerEvent) { - if (!onAddAssetTrigger || scripts.length === 0) return - const sets = scripts.map((id) => onDirectiveTargets(model.nodes, model.edges, id)) - const eligible = new Set([...sets[0]].filter((a) => sets.every((t) => t.has(a)))) + [...(flowEl?.querySelectorAll('.svelte-flow__node') ?? [])] + .find((el) => within(el, x, y)) + ?.getAttribute('data-id') ?? undefined + const overBlank = (x: number, y: number) => + !!flowEl && + within(flowEl, x, y) && + ![ + ...flowEl.querySelectorAll( + '.svelte-flow__panel, .svelte-flow__controls, .svelte-flow__minimap' + ) + ].some((el) => within(el, x, y)) + const readsAsset = (assetId: string, scriptId: string) => + model.edges.some((x) => x.kind === 'lineage-read' && x.source === assetId && x.target === scriptId) + const assetOf = (assetId: string) => { + const a = model.nodes.find((n) => n.id === assetId)?.data + return a ? { kind: a.asset_kind as AssetKind, path: a.path as string } : undefined + } + function subscribe(assetId: string, scripts: string[]) { + const asset = assetOf(assetId) + if (!asset) return + onAddAssetTrigger?.({ + asset, + scripts: scripts.map((id) => ({ + script: id.replace(/^script:/, ''), + reads: readsAsset(assetId, id) + })) + }) + } + /** `threshold`: pixels the pointer must travel before it counts as a drag, so a + * plain click on the handle still clicks. */ + function trackDrag( + e: PointerEvent, + start: { sourceId: string; feed: boolean; eligible: () => Set; threshold: number }, + drop: (target: string | undefined, at: { x: number; y: number }, blank: boolean) => void + ) { const from = { x: e.clientX, y: e.clientY } - onDrag = { sourceId, scripts, from, to: from, eligible, over: undefined } + let started = false + const begin = () => { + started = true + onDrag = { + sourceId: start.sourceId, + from, + to: from, + eligible: start.eligible(), + over: undefined, + feed: start.feed, + blank: false + } + } + if (start.threshold === 0) begin() const move = (ev: PointerEvent) => { + if (!started) { + if (Math.hypot(ev.clientX - from.x, ev.clientY - from.y) < start.threshold) return + begin() + } if (!onDrag) return onDrag.to = { x: ev.clientX, y: ev.clientY } onDrag.over = nodeUnder(ev.clientX, ev.clientY) + onDrag.blank = !onDrag.over && overBlank(ev.clientX, ev.clientY) } const up = (ev: PointerEvent) => { window.removeEventListener('pointermove', move) const drag = onDrag onDrag = undefined + if (!started || !drag) return + // The release is not a click on whatever it lands on. + const swallow = (c: MouseEvent) => { + c.stopPropagation() + c.preventDefault() + } + window.addEventListener('click', swallow, { capture: true, once: true }) + setTimeout(() => window.removeEventListener('click', swallow, { capture: true }), 0) const target = nodeUnder(ev.clientX, ev.clientY) - if (!drag || !target || !drag.eligible.has(target)) return - const a = model.nodes.find((n) => n.id === target)?.data - if (!a) return - const asset = { kind: a.asset_kind as AssetKind, path: a.path as string } - onAddAssetTrigger?.({ - asset, - scripts: drag.scripts.map((id) => ({ - script: id.replace(/^script:/, ''), - reads: model.edges.some( - (x) => x.kind === 'lineage-read' && x.source === target && x.target === id - ) - })) - }) + const at = { x: ev.clientX, y: ev.clientY } + drop(target && drag.eligible.has(target) ? target : undefined, at, !target && drag.blank) } window.addEventListener('pointermove', move) window.addEventListener('pointerup', up, { once: true }) } + function startOnDrag(sourceId: string, scripts: string[], e: PointerEvent) { + if (!onAddAssetTrigger || scripts.length === 0) return + trackDrag( + e, + { + sourceId, + feed: false, + threshold: 0, + eligible: () => { + const sets = scripts.map((id) => onDirectiveTargets(model.nodes, model.edges, id)) + return new Set([...sets[0]].filter((a) => sets.every((t) => t.has(a)))) + } + }, + (target) => { + if (target) subscribe(target, scripts) + } + ) + } + // Where a "+" dropped on the blank canvas opened its add menu. + let feedMenu = $state< + | { at: { x: number; y: number }; asset: { kind: AssetKind; path: string }; data: any } + | undefined + >(undefined) + let feedMenuSignal = $state(0) + function startFeedDrag(assetId: string, data: any, e: PointerEvent) { + trackDrag( + e, + { + sourceId: assetId, + feed: true, + threshold: 5, + // A drop target is a node whose scripts can all subscribe to the asset. + eligible: () => { + if (!onAddAssetTrigger) return new Set() + const targets = new Map>() + const can = (id: string) => { + if (!targets.has(id)) targets.set(id, onDirectiveTargets(model.nodes, model.edges, id)) + return targets.get(id)!.has(assetId) + } + return new Set( + view.nodes + .filter((n) => { + const scripts = dragScripts(n) + return n.id !== assetId && scripts.length > 0 && scripts.every(can) + }) + .map((n) => n.id) + ) + } + }, + (target, at, blank) => { + const node = target ? view.nodes.find((n) => n.id === target) : undefined + if (node) subscribe(assetId, dragScripts(node)) + else if (blank) { + const asset = assetOf(assetId) + if (!asset) return + feedMenu = { at, asset, data } + feedMenuSignal++ + } + } + ) + } // Pipeline scripts a node's top dot drags for: the script itself, or, in the // assets-only view, the scripts building the asset. function dragScripts(n: { id: string; type: string; data: any }): string[] { @@ -522,6 +639,8 @@ // script that is folded away. | 'asset-flow' unsaved?: boolean + // Assets-only `asset-flow`: a write to the source reruns a folded script. + reactive?: boolean // Muted read edge: a ducklake/s3 input read every run whose (default) // auto cascade trigger is suppressed by `// mute` / `// mute all`. // Rendered with a bell-off badge — auto-wiring is the norm, so we mark @@ -1541,6 +1660,9 @@ ...(foldedFixes.length ? { foldedFixes } : {}), ...(scriptsToDrag.length ? { onStartOnDrag: (e: PointerEvent) => startOnDrag(n.id, scriptsToDrag, e) } + : {}), + ...(n.type === 'asset' && onAddScriptForAsset + ? { onStartFeedDrag: (e: PointerEvent) => startFeedDrag(n.id, n.data, e) } : {}) } return { @@ -1673,8 +1795,16 @@ markerColor = 'rgb(59 130 246)' break case 'asset-flow': - style = 'stroke: rgb(59 130 246); stroke-width: 1.25px;' - markerColor = 'rgb(59 130 246)' + // Assets-only: a write upstream reruns what builds the target + // (solid, like a trigger), or the target only reads it (dashed gray). + if (e.reactive) { + style = 'stroke: rgb(59 130 246); stroke-width: 1.5px;' + markerColor = 'rgb(59 130 246)' + } else { + style = 'stroke: rgb(156 163 175); stroke-width: 1px;' + strokeDasharray = '4 3' + markerColor = 'rgb(156 163 175)' + } break case 'lineage-read': style = 'stroke: rgb(156 163 175); stroke-width: 1px;' @@ -1886,6 +2016,7 @@
- + {#if !(onDrag.feed && onDrag.blank)} + + {/if} + {#if onDrag.feed && onDrag.blank} +
+ +
+ {/if} +{/if} + +{#if feedMenu && onAddScriptForAsset} +
+ + {#snippet trigger({ open })} + {#if open} + + {/if} + {/snippet} + +
{/if} {#if hoveredEdge && hoveredEdgeText} diff --git a/frontend/src/lib/components/assets/AssetGraph/AssetNode.svelte b/frontend/src/lib/components/assets/AssetGraph/AssetNode.svelte index ede2f3815e..2163398372 100644 --- a/frontend/src/lib/components/assets/AssetGraph/AssetNode.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/AssetNode.svelte @@ -7,7 +7,8 @@ import NodeActionsMenu from './NodeActionsMenu.svelte' import NodeFixButton, { type NodeFix } from './NodeFixButton.svelte' import NodeOnDot from './NodeOnDot.svelte' - import PipelineInsertMenu, { type PipelineInsertPick } from './PipelineInsertMenu.svelte' + import AddDownstreamMenu from './AddDownstreamMenu.svelte' + import AddDownstreamPill from './AddDownstreamPill.svelte' import { ArrowUpRight, Code2, @@ -15,7 +16,6 @@ History, Play, Loader2, - Plus, ShieldCheck, ShieldAlert, CheckCircle2, @@ -26,7 +26,6 @@ import type { ScriptLang } from '$lib/gen' import { enterpriseLicense } from '$lib/stores' import { sendUserToast, type Item } from '$lib/utils' - import { PIPELINE_LANGUAGES } from './pipelineLanguages' import type { PipelineOutputKind } from './pipelineTemplates' import type { DbtAssetProvenance } from './types' import DbtIcon from '$lib/components/icons/DbtIcon.svelte' @@ -116,6 +115,9 @@ ) => void pathPrefix?: string defaultPathSuffix?: string + /** Starts dragging the "+" onto a script (subscribing it to this asset) + * or onto the blank canvas (opening the add menu there). */ + onStartFeedDrag?: (e: PointerEvent) => void // Producer scripts/flows that write to this asset, supplied by // the canvas from the graph's write/rw edges. Drives the on-hover // "Run" button. Includes unsaved/draft producers so the button @@ -212,18 +214,7 @@ } } - function handlePick(pick: PipelineInsertPick) { - if (pick.kindId === 'pipeline_script' && pick.language && pick.path) { - data.onAddScript?.( - { kind: data.asset_kind, path: data.path }, - pick.language as ScriptLang, - pick.path, - (pick.outputKind ?? 'none') as PipelineOutputKind, - pick.aiPrompt, - { outputAsset: pick.outputAsset } - ) - } - } + // A red asset needs fixing where its error comes from, not a downstream step; // and a step downstream of what nothing writes would never run on its writes. @@ -669,41 +660,33 @@ script creation, half-overlapping it like the flow editor's between-step inserter. -->
- {#snippet trigger({ open })} - + {/snippet} - +
{/if}
diff --git a/frontend/src/lib/components/assets/AssetGraph/assetsOnlyView.ts b/frontend/src/lib/components/assets/AssetGraph/assetsOnlyView.ts index d9c102018c..ae1882243d 100644 --- a/frontend/src/lib/components/assets/AssetGraph/assetsOnlyView.ts +++ b/frontend/src/lib/components/assets/AssetGraph/assetsOnlyView.ts @@ -10,6 +10,8 @@ export type ViewEdge = { unsaved?: boolean /** Assets-only edges: the folded runnables the data goes through. */ via?: string[] + /** Assets-only edges: a write to the source reruns one of `via`. */ + reactive?: boolean } /** The assets-only node standing for the scripts that build no asset. */ @@ -63,6 +65,9 @@ export function assetsOnlyView( } else if (e.kind === 'trigger-native') push(triggersOf, e.target, e) } + const triggered = new Set( + edges.filter((e) => e.kind === 'trigger-asset').map((e) => `${e.source}\n${e.target}`) + ) const out = new Map() for (const [assetId, writes] of producersOf) { for (const w of writes) { @@ -72,8 +77,10 @@ export function assetsOnlyView( const id = `flow:${r.source}->${assetId}` const prev = out.get(id) const unsaved = !!(w.unsaved || r.unsaved) + const reactive = triggered.has(`${r.source}\n${w.source}`) if (prev) { prev.unsaved = prev.unsaved && unsaved + prev.reactive ||= reactive if (!prev.via?.includes(w.source)) prev.via = [...(prev.via ?? []), w.source] } else { out.set(id, { @@ -82,6 +89,7 @@ export function assetsOnlyView( target: assetId, kind: 'asset-flow', unsaved, + reactive, via: [w.source] }) } @@ -136,8 +144,10 @@ export function assetsOnlyView( for (const r of inputsOf.get(id) ?? []) { const eid = `flow:${r.source}->${NO_ASSET_NODE_ID}` const prev = out.get(eid) + const reactive = triggered.has(`${r.source}\n${id}`) if (prev) { prev.unsaved = prev.unsaved && !!r.unsaved + prev.reactive ||= reactive if (!prev.via?.includes(id)) prev.via = [...(prev.via ?? []), id] } else { out.set(eid, { @@ -146,6 +156,7 @@ export function assetsOnlyView( target: NO_ASSET_NODE_ID, kind: 'asset-flow', unsaved: !!r.unsaved, + reactive, via: [id] }) } diff --git a/frontend/src/lib/components/assets/AssetGraph/edgeDescription.test.ts b/frontend/src/lib/components/assets/AssetGraph/edgeDescription.test.ts index a0dea60700..2aff7a0e81 100644 --- a/frontend/src/lib/components/assets/AssetGraph/edgeDescription.test.ts +++ b/frontend/src/lib/components/assets/AssetGraph/edgeDescription.test.ts @@ -46,6 +46,6 @@ describe('describeEdge', () => { nameOf, dataOf ) - ).toEqual({ subject: 'clean', phrase: 'is built from', object: 'raw', note: 'By Clean.' }) + ).toEqual({ subject: 'clean', phrase: 'is built from', object: 'raw', note: 'By Clean. A write to raw does not rerun it.' }) }) }) diff --git a/frontend/src/lib/components/assets/AssetGraph/edgeDescription.ts b/frontend/src/lib/components/assets/AssetGraph/edgeDescription.ts index 486bd3dab5..4267dde92c 100644 --- a/frontend/src/lib/components/assets/AssetGraph/edgeDescription.ts +++ b/frontend/src/lib/components/assets/AssetGraph/edgeDescription.ts @@ -13,6 +13,7 @@ export type DescribedEdge = { muted?: boolean missing?: boolean via?: string[] + reactive?: boolean macro_names?: string[] via_use?: boolean } @@ -71,15 +72,18 @@ export function describeEdge( } case 'asset-flow': { const via = (e.via ?? []).map(nameOf).join(', ') + const rerun = e.reactive + ? `Each write to ${src} reruns it.` + : `A write to ${src} does not rerun it.` // The scripts that build nothing only read: name them, not the node. if (e.target === NO_ASSET_NODE_ID) { - return { subject: src, phrase: 'is read by', object: via || tgt } + return { subject: src, phrase: 'is read by', object: via || tgt, note: rerun } } return { subject: tgt, phrase: 'is built from', object: src, - note: via ? `By ${via}.` : undefined + note: via ? `By ${via}. ${rerun}` : rerun } } case 'dbt-ref':