mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-12 16:02:56 +00:00
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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
7c840fb54e
commit
f80d2594b1
@@ -0,0 +1,73 @@
|
||||
<!--
|
||||
@component
|
||||
The menu adding a pipeline script downstream of an asset: opened from the asset's
|
||||
"+", or where that "+" was dropped on the blank canvas.
|
||||
-->
|
||||
<script lang="ts" module>
|
||||
import type { ScriptLang } from '$lib/gen'
|
||||
import type { AssetKind } from '$lib/components/assets/lib'
|
||||
import type { PipelineInsertOptions } from './PipelineInsertMenu.svelte'
|
||||
import type { PipelineOutputKind } from './pipelineTemplates'
|
||||
|
||||
export type AddScriptForAsset = (
|
||||
asset: { kind: AssetKind; path: string },
|
||||
language: ScriptLang,
|
||||
scriptPath: string,
|
||||
outputKind: PipelineOutputKind,
|
||||
aiPrompt?: string,
|
||||
options?: PipelineInsertOptions
|
||||
) => void
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { Code2 } from 'lucide-svelte'
|
||||
import type { Snippet } from 'svelte'
|
||||
import PipelineInsertMenu, { type PipelineInsertPick } from './PipelineInsertMenu.svelte'
|
||||
import { PIPELINE_LANGUAGES } from './pipelineLanguages'
|
||||
|
||||
let {
|
||||
asset,
|
||||
onAddScript,
|
||||
pathPrefix = '',
|
||||
defaultPathSuffix = '',
|
||||
openSignal,
|
||||
trigger
|
||||
}: {
|
||||
asset: { kind: AssetKind; path: string }
|
||||
onAddScript: AddScriptForAsset
|
||||
pathPrefix?: string
|
||||
defaultPathSuffix?: string
|
||||
openSignal?: number
|
||||
trigger: Snippet<[{ open: boolean }]>
|
||||
} = $props()
|
||||
|
||||
function handlePick(pick: PipelineInsertPick) {
|
||||
if (pick.kindId === 'pipeline_script' && pick.language && pick.path) {
|
||||
onAddScript(
|
||||
asset,
|
||||
pick.language as ScriptLang,
|
||||
pick.path,
|
||||
(pick.outputKind ?? 'none') as PipelineOutputKind,
|
||||
pick.aiPrompt,
|
||||
{ outputAsset: pick.outputAsset }
|
||||
)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<PipelineInsertMenu
|
||||
kinds={[
|
||||
{
|
||||
id: 'pipeline_script',
|
||||
label: 'Add downstream pipeline script',
|
||||
description: 'Triggered when this asset changes',
|
||||
icon: Code2
|
||||
}
|
||||
]}
|
||||
languages={PIPELINE_LANGUAGES as any}
|
||||
{pathPrefix}
|
||||
{defaultPathSuffix}
|
||||
{openSignal}
|
||||
onPick={handlePick}
|
||||
{trigger}
|
||||
/>
|
||||
@@ -0,0 +1,23 @@
|
||||
<!--
|
||||
@component
|
||||
The "+" on an asset's bottom edge: a circle at rest, widened into a labelled pill
|
||||
while its menu is open or while it is being dragged. The label animates its
|
||||
max-width (width can't transition to auto) and never wraps.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Plus } from 'lucide-svelte'
|
||||
|
||||
let { expanded }: { expanded: boolean } = $props()
|
||||
</script>
|
||||
|
||||
<span
|
||||
class="group/add h-6 min-w-6 rounded-full flex items-center justify-center bg-surface border border-gray-400 dark:border-gray-600 text-secondary hover:text-primary hover:border-gray-500 shadow-sm leading-none overflow-hidden transition-[padding] duration-200 ease-out data-[open=true]:pl-1"
|
||||
data-open={expanded}
|
||||
>
|
||||
<Plus size={16} strokeWidth={2.5} class="shrink-0" />
|
||||
<span
|
||||
class="max-w-0 opacity-0 whitespace-nowrap text-3xs font-normal transition-[max-width,opacity,margin] duration-200 ease-out group-data-[open=true]/add:max-w-32 group-data-[open=true]/add:opacity-100 group-data-[open=true]/add:ml-1 group-data-[open=true]/add:mr-2"
|
||||
>
|
||||
Add downstream step
|
||||
</span>
|
||||
</span>
|
||||
@@ -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<string>
|
||||
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<string>; 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<string, Set<string>>()
|
||||
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 @@
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="w-full h-full relative"
|
||||
bind:this={flowEl}
|
||||
bind:clientWidth={paneWidth}
|
||||
onclickcapture={handleBackgroundClick}
|
||||
oncontextmenu={handleContextMenu}
|
||||
@@ -2017,8 +2148,39 @@
|
||||
stroke-width="1.5"
|
||||
stroke-dasharray="4 3"
|
||||
/>
|
||||
<circle cx={onDrag.to.x} cy={onDrag.to.y} r="3" class="fill-blue-500" />
|
||||
{#if !(onDrag.feed && onDrag.blank)}
|
||||
<circle cx={onDrag.to.x} cy={onDrag.to.y} r="3" class="fill-blue-500" />
|
||||
{/if}
|
||||
</svg>
|
||||
{#if onDrag.feed && onDrag.blank}
|
||||
<div
|
||||
class="fixed z-50 pointer-events-none -translate-x-1/2 -translate-y-1/2"
|
||||
style="left: {onDrag.to.x}px; top: {onDrag.to.y}px;"
|
||||
>
|
||||
<AddDownstreamPill expanded />
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if feedMenu && onAddScriptForAsset}
|
||||
<div
|
||||
class="fixed z-10 -translate-x-1/2 -translate-y-1/2"
|
||||
style="left: {feedMenu.at.x}px; top: {feedMenu.at.y}px;"
|
||||
>
|
||||
<AddDownstreamMenu
|
||||
asset={feedMenu.asset}
|
||||
onAddScript={onAddScriptForAsset}
|
||||
pathPrefix={feedMenu.data.pathPrefix ?? pathPrefix}
|
||||
defaultPathSuffix={feedMenu.data.defaultPathSuffix ?? defaultPathSuffix}
|
||||
openSignal={feedMenuSignal}
|
||||
>
|
||||
{#snippet trigger({ open })}
|
||||
{#if open}
|
||||
<AddDownstreamPill expanded />
|
||||
{/if}
|
||||
{/snippet}
|
||||
</AddDownstreamMenu>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if hoveredEdge && hoveredEdgeText}
|
||||
|
||||
@@ -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. -->
|
||||
<div class="absolute left-1/2 -bottom-3 -translate-x-1/2 z-10">
|
||||
<PipelineInsertMenu
|
||||
kinds={[
|
||||
{
|
||||
id: 'pipeline_script',
|
||||
label: 'Add downstream pipeline script',
|
||||
description: 'Triggered when this asset changes',
|
||||
icon: Code2
|
||||
}
|
||||
]}
|
||||
languages={PIPELINE_LANGUAGES as any}
|
||||
pathPrefix={data.pathPrefix ?? ''}
|
||||
defaultPathSuffix={data.defaultPathSuffix ?? ''}
|
||||
onPick={handlePick}
|
||||
<AddDownstreamMenu
|
||||
asset={{ kind: data.asset_kind, path: data.path }}
|
||||
onAddScript={data.onAddScript!}
|
||||
pathPrefix={data.pathPrefix}
|
||||
defaultPathSuffix={data.defaultPathSuffix}
|
||||
>
|
||||
{#snippet trigger({ open })}
|
||||
<!-- A circle at rest; hovering widens it into a labelled pill, which
|
||||
stays open while its menu is (the menu can open under the pointer
|
||||
and take the hover away). The label animates its max-width (width
|
||||
can't transition to auto) and never wraps. -->
|
||||
<!-- A click opens the menu; a drag goes to the canvas, which
|
||||
tells a real drag from a click by how far the pointer moves. -->
|
||||
<button
|
||||
type="button"
|
||||
class="nopan nodrag block"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
class="group/add h-6 min-w-6 rounded-full flex items-center justify-center bg-surface border border-gray-400 dark:border-gray-600 text-secondary hover:text-primary hover:border-gray-500 shadow-sm leading-none overflow-hidden transition-[padding] duration-200 ease-out hover:pl-1 data-[open=true]:pl-1"
|
||||
data-open={open}
|
||||
onpointerdown={(e) => {
|
||||
if (e.button !== 0) return
|
||||
e.preventDefault()
|
||||
data.onStartFeedDrag?.(e)
|
||||
}}
|
||||
aria-label="Add downstream step"
|
||||
title={data.onStartFeedDrag
|
||||
? 'Click to add a downstream step, or drag onto a script to run it after each write'
|
||||
: undefined}
|
||||
>
|
||||
<Plus size={16} strokeWidth={2.5} class="shrink-0" />
|
||||
<span
|
||||
class="max-w-0 opacity-0 whitespace-nowrap text-3xs font-normal transition-[max-width,opacity,margin] duration-200 ease-out group-hover/add:max-w-32 group-hover/add:opacity-100 group-hover/add:ml-1 group-hover/add:mr-2 group-data-[open=true]/add:max-w-32 group-data-[open=true]/add:opacity-100 group-data-[open=true]/add:ml-1 group-data-[open=true]/add:mr-2"
|
||||
>
|
||||
Add downstream step
|
||||
</span>
|
||||
<AddDownstreamPill expanded={open} />
|
||||
</button>
|
||||
{/snippet}
|
||||
</PipelineInsertMenu>
|
||||
</AddDownstreamMenu>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -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<N extends ViewNode, E extends ViewEdge>(
|
||||
} 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<string, ViewEdge>()
|
||||
for (const [assetId, writes] of producersOf) {
|
||||
for (const w of writes) {
|
||||
@@ -72,8 +77,10 @@ export function assetsOnlyView<N extends ViewNode, E extends ViewEdge>(
|
||||
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<N extends ViewNode, E extends ViewEdge>(
|
||||
target: assetId,
|
||||
kind: 'asset-flow',
|
||||
unsaved,
|
||||
reactive,
|
||||
via: [w.source]
|
||||
})
|
||||
}
|
||||
@@ -136,8 +144,10 @@ export function assetsOnlyView<N extends ViewNode, E extends ViewEdge>(
|
||||
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<N extends ViewNode, E extends ViewEdge>(
|
||||
target: NO_ASSET_NODE_ID,
|
||||
kind: 'asset-flow',
|
||||
unsaved: !!r.unsaved,
|
||||
reactive,
|
||||
via: [id]
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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.' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user