From be5e651f202773879d439401fec6c0bcbc72ee2c Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Wed, 30 Sep 2026 14:33:52 +0200 Subject: [PATCH] feat: edit an asset's producing script from its pane and multi-script chip Co-Authored-By: Claude Opus 5.5 (1M context) --- .../assets/AssetGraph/AssetGraphCanvas.svelte | 29 ++++- .../AssetGraph/AssetGraphDetailsPane.svelte | 41 +++++- .../assets/AssetGraph/AssetNode.svelte | 120 ++++++++++++++---- .../AssetGraph/PipelineGraphEditor.svelte | 17 +++ .../assets/AssetGraph/assetNodeWidth.ts | 12 +- .../assets/AssetGraph/assetsOnlyView.test.ts | 14 +- .../assets/AssetGraph/assetsOnlyView.ts | 11 +- .../assets/AssetGraph/upstreamScriptItems.ts | 21 +++ 8 files changed, 231 insertions(+), 34 deletions(-) create mode 100644 frontend/src/lib/components/assets/AssetGraph/upstreamScriptItems.ts diff --git a/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte b/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte index 2a5988122e..dac7d93d98 100644 --- a/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte @@ -1021,7 +1021,21 @@ ? undefined : { none: true as const } : u.multiple || !r - ? { multiple: true as const } + ? { + multiple: true as const, + scripts: (u.multiple ? u.runnableIds : []) + .map((id) => runnables.get(id)?.data) + .filter((d) => d != undefined) + .map((d) => ({ + runnableId: `${d.runnable_kind}:${d.path}`, + path: d.path as string, + summary: d.summary as string | undefined, + language: d.language, + unsaved: d.unsaved as boolean | undefined, + onOpen: () => + onselect?.({ kind: 'runnable', runnable_kind: d.runnable_kind, path: d.path }) + })) + } : { runnableId: u.runnableId, path: r.path, @@ -1059,7 +1073,8 @@ ? 'Multiple upstream nodes' : `${upstream.trigger.label}${upstream.trigger.draft ? ' · draft' : ''}`, runState: 'runState' in upstream && !!upstream.runState, - scriptChip: 'runnableId' in upstream + scriptChips: + 'runnableId' in upstream ? 1 : 'scripts' in upstream ? (upstream.scripts?.length ?? 0) : 0 }) : undefined return { @@ -1218,7 +1233,15 @@ // never re-runs the layout. const up = n.type === 'asset' ? n.data.upstream : undefined const data = - up?.runnableId !== undefined + up?.scripts + ? { + ...n.data, + upstream: { + ...up, + selected: up.scripts.some((sc: { runnableId: string }) => sc.runnableId === selectedId) + } + } + : up?.runnableId !== undefined ? { ...n.data, upstream: { diff --git a/frontend/src/lib/components/assets/AssetGraph/AssetGraphDetailsPane.svelte b/frontend/src/lib/components/assets/AssetGraph/AssetGraphDetailsPane.svelte index 00c14ad4e8..af6f30a0bc 100644 --- a/frontend/src/lib/components/assets/AssetGraph/AssetGraphDetailsPane.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/AssetGraphDetailsPane.svelte @@ -20,8 +20,11 @@ SquareFunction, Trash2, X, - Pencil + Pencil, + ChevronDown } from 'lucide-svelte' + import DropdownV2 from '$lib/components/DropdownV2.svelte' + import { upstreamScriptItems, type UpstreamScript } from './upstreamScriptItems' import { inferArgs } from '$lib/infer' import { emptySchema, sendUserToast } from '$lib/utils' import type { Schema } from '$lib/common' @@ -157,6 +160,10 @@ path: string unsaved?: boolean }> + /** The selected asset's producing scripts, for its "Edit script" action, + * which calls `onEditScript` with the one picked. */ + producerScripts?: UpstreamScript[] + onEditScript?: (path: string) => void // Pipeline-wide column-lineage graph (built by the parent page from the // resolved graph). Drives the transitive column-lineage trace shown for a // selected materialized asset. @@ -305,6 +312,8 @@ onScriptRenamed, onScriptRemoved, selectionProducers = [], + producerScripts = [], + onEditScript, selectionColumnGraph, selectionColumnLoading = false, selectionColumnTruncated = false, @@ -1018,6 +1027,36 @@ {/if}
+ {#if selection?.kind === 'asset' && onEditScript && producerScripts.length === 1} + + {:else if selection?.kind === 'asset' && onEditScript && producerScripts.length > 1} + + {#snippet buttonReplacement()} + + {/snippet} + + {/if} {#if readOnly && onRequestEdit} +{/snippet} + {#snippet upstreamRow()} {#if upstream} {@const trigger = producer?.trigger} {@const chipClass = twMerge( - 'truncate rounded-md border px-1.5 py-0.5 text-3xs leading-none font-normal', + 'truncate rounded-md border px-1.5 py-0.5 text-3xs leading-none font-normal bg-surface', trigger?.missing ? 'border-red-300 dark:border-red-600 text-red-700 dark:text-red-300' : 'border-gray-300 dark:border-gray-600 text-secondary', @@ -462,28 +519,47 @@ {#if producer.runState} {/if} - + + {#snippet buttonReplacement()} + + {#each coProducers.slice(0, 2) as s (s.path)} + {#if s.language} + + {:else} + + {/if} + {/each} + {#if coProducers.length > 2} + +{coProducers.length - 2} + {/if} + + {/snippet} + +
{/if} {/snippet} @@ -566,7 +642,7 @@ > Add downstream step diff --git a/frontend/src/lib/components/assets/AssetGraph/PipelineGraphEditor.svelte b/frontend/src/lib/components/assets/AssetGraph/PipelineGraphEditor.svelte index f7c4a8a465..c98951462d 100644 --- a/frontend/src/lib/components/assets/AssetGraph/PipelineGraphEditor.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/PipelineGraphEditor.svelte @@ -279,6 +279,21 @@ } }) + // The selected asset's producing scripts, named and iconed from the graph, for + // the pane's "Edit script". + let producerScripts = $derived( + selectionProducers + .filter((p) => p.kind === 'script') + .map((p) => { + const r = displayGraph.runnables.find((x) => x.usage_kind === 'script' && x.path === p.path) + return { path: p.path, summary: r?.summary, language: r?.language } + }) + ) + function editProducerScript(path: string) { + if (mode !== 'edit') onRequestEdit?.() + onSelect({ kind: 'runnable', runnable_kind: 'script', path }) + } + // ===================== Draft autosave (persistDrafts only) ===================== // All of this folder's in-flight drafts live in ONE per-user DB draft (typ // `data_pipeline`) keyed at the folder, syncing across devices + surfacing in @@ -576,6 +591,8 @@ {localScriptsVersion} selection={activeDraft ? undefined : editor.selection} selectionProducers={activeDraft ? [] : selectionProducers} + {producerScripts} + onEditScript={editProducerScript} {selectionColumnGraph} {selectionColumnLoading} {selectionColumnTruncated} diff --git a/frontend/src/lib/components/assets/AssetGraph/assetNodeWidth.ts b/frontend/src/lib/components/assets/AssetGraph/assetNodeWidth.ts index dc0dbc18ce..66efcbb1d4 100644 --- a/frontend/src/lib/components/assets/AssetGraph/assetNodeWidth.ts +++ b/frontend/src/lib/components/assets/AssetGraph/assetNodeWidth.ts @@ -28,7 +28,8 @@ export function assetsOnlyNodeWidth(n: { title: string chip: string runState: boolean - scriptChip: boolean + /** Producing scripts: one chip for one, two icons and a "+N" for more. */ + scriptChips: number }): number { const icon = 12 + 14 + 10 const text = Math.max( @@ -37,8 +38,13 @@ export function assetsOnlyNodeWidth(n: { textWidth(n.chip, 0.65) + 12 + 2 ) // The run chip is sized for a two-digit count, so a new run never re-lays the graph. - const trailing = - (n.runState ? 12 + 10 + 2 + textWidth('×99', 0.65) + 4 : 0) + (n.scriptChip ? 20 : 0) + 8 + const scripts = + n.scriptChips === 0 + ? 0 + : n.scriptChips === 1 + ? 20 + : 8 + 2 + 12 * 2 + 2 + (n.scriptChips > 2 ? textWidth(`+${n.scriptChips - 2}`, 0.65) + 2 : 0) + const trailing = (n.runState ? 12 + 10 + 2 + textWidth('×99', 0.65) + 4 : 0) + scripts + 8 const width = Math.ceil(icon + text + 4 + trailing + 2 + 8) return Math.min(NODE.width, Math.max(MIN_WIDTH, width)) } diff --git a/frontend/src/lib/components/assets/AssetGraph/assetsOnlyView.test.ts b/frontend/src/lib/components/assets/AssetGraph/assetsOnlyView.test.ts index 7ff448605d..d0b0daf91d 100644 --- a/frontend/src/lib/components/assets/AssetGraph/assetsOnlyView.test.ts +++ b/frontend/src/lib/components/assets/AssetGraph/assetsOnlyView.test.ts @@ -51,7 +51,10 @@ describe('assetsOnlyView', () => { runnableId: 'script:f/p/clean', trigger: { kind: 'schedule', data: { schedule: '0 0 4 * * *' } } }) - expect(v.upstream.get('asset:ducklake:report')).toEqual({ multiple: true }) + expect(v.upstream.get('asset:ducklake:report')).toEqual({ + multiple: true, + runnableIds: ['script:f/p/report', 'script:f/p/other'] + }) }) }) @@ -81,6 +84,15 @@ describe('upstreamDeletion', () => { expect(upstreamDeletion(u, script, new Map([['asset:x', u]]))?.trigger).toBeUndefined() }) + it('deletes nothing when the script also co-produces another asset', () => { + const u = own(schedule(['f/p/a'])) + const all = new Map([ + ['asset:x', u], + ['asset:y', { multiple: true, runnableIds: ['script:f/p/a', 'script:f/p/b'] }] + ]) + expect(upstreamDeletion(u, script, all)).toBeUndefined() + }) + it('deletes nothing when the script builds another asset too', () => { const u = own(schedule(['f/p/a'])) const all = new Map([ diff --git a/frontend/src/lib/components/assets/AssetGraph/assetsOnlyView.ts b/frontend/src/lib/components/assets/AssetGraph/assetsOnlyView.ts index c786c6ce53..d83ded177f 100644 --- a/frontend/src/lib/components/assets/AssetGraph/assetsOnlyView.ts +++ b/frontend/src/lib/components/assets/AssetGraph/assetsOnlyView.ts @@ -18,7 +18,8 @@ export type AssetUpstreamDelete = { /** What an asset shows of the script and trigger that produce it, in the assets-only view. */ export type AssetUpstream = - | { multiple: true } + /** Several producing scripts, or one with several triggers. */ + | { multiple: true; runnableIds: string[] } | { multiple: false /** Canvas id of the producing runnable (`script:`). */ @@ -75,7 +76,7 @@ export function assetsOnlyView( for (const [assetId, writes] of producersOf) { const runnableIds = [...new Set(writes.map((w) => w.source))] if (runnableIds.length !== 1) { - upstream.set(assetId, { multiple: true }) + upstream.set(assetId, { multiple: true, runnableIds }) continue } const runnableId = runnableIds[0] @@ -89,7 +90,7 @@ export function assetsOnlyView( ).values() ] if (triggers.length > 1) { - upstream.set(assetId, { multiple: true }) + upstream.set(assetId, { multiple: true, runnableIds }) continue } const t = triggers[0] @@ -119,7 +120,9 @@ export function upstreamDeletion( ): AssetUpstreamDelete | undefined { if (r.runnable_kind !== 'script') return undefined const producesOthers = - [...all.values()].filter((o) => !o.multiple && o.runnableId === u.runnableId).length > 1 + [...all.values()].filter((o) => + o.multiple ? o.runnableIds.includes(u.runnableId) : o.runnableId === u.runnableId + ).length > 1 if (producesOthers) return undefined const t = u.trigger const kind = t?.kind as NativeTriggerKind | undefined diff --git a/frontend/src/lib/components/assets/AssetGraph/upstreamScriptItems.ts b/frontend/src/lib/components/assets/AssetGraph/upstreamScriptItems.ts new file mode 100644 index 0000000000..1768fa6c94 --- /dev/null +++ b/frontend/src/lib/components/assets/AssetGraph/upstreamScriptItems.ts @@ -0,0 +1,21 @@ +import type { Item } from '$lib/utils' +import type { ScriptLang } from '$lib/gen' +import LanguageIcon from '$lib/components/common/languageIcons/LanguageIcon.svelte' +import { Code2 } from 'lucide-svelte' + +export type UpstreamScript = { path: string; summary?: string; language?: ScriptLang } + +/** A menu of the scripts that produce an asset, to pick the one to open. */ +export function upstreamScriptItems( + scripts: UpstreamScript[], + onPick: (path: string) => void +): Item[] { + return scripts.map((s) => ({ + displayName: s.summary || s.path, + description: s.summary ? s.path : undefined, + ...(s.language + ? { icon: LanguageIcon, iconProps: { lang: s.language, width: 14, height: 14 } } + : { icon: Code2 }), + action: () => onPick(s.path) + })) +}