feat: folded errors and edge toggles in assets-only view, preset writer wizard

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-09-30 17:28:40 +02:00
co-authored by Claude Opus 5.5
parent 7e972d4319
commit cee55995e9
6 changed files with 257 additions and 126 deletions
@@ -230,13 +230,11 @@
nodeFixes?: ReadonlyMap<string, NodeFixSpec>
/** "Archive" for someone who can only archive the script the delete removes. */
assetDeleteVerb?: 'Delete' | 'Archive'
/** Makes a script run after each write to an asset it reads, or stop. */
/** Makes scripts run after each write to an asset they read, or stop. `reads`
* decides whether stopping also mutes the asset. */
onEdgeAction?: (a: {
action: 'listen' | 'stop'
script: string
asset: { kind: AssetKind; path: string }
/** Whether the script reads the asset, which decides whether stopping mutes it. */
reads?: boolean
targets: Array<{ script: string; asset: { kind: AssetKind; path: string }; reads: boolean }>
}) => void
}
let {
@@ -305,29 +303,51 @@
}
// What the hovered edge lets you change: a plain read can become a trigger, and
// an asset trigger can be dropped. Both rewrite the script's `// on` header.
let hoveredEdgeAction = $derived.by(() => {
// What the hovered edge lets you change: a script that only reads the upstream
// asset can rerun on each write to it, and one it triggers can stop. On an
// assets-only edge, those are the scripts folded into it; both rewrite `// on`.
type EdgeTarget = { script: string; asset: { kind: AssetKind; path: string }; reads: boolean }
let hoveredEdgeActions = $derived.by(() => {
const e = hoveredEdge && onEdgeAction && view.edges.find((x) => x.id === hoveredEdge!.id)
if (!e || (e.kind !== 'lineage-read' && e.kind !== 'trigger-asset')) return undefined
const asset = view.nodes.find((n) => n.id === e.source)?.data
const script = view.nodes.find((n) => n.id === e.target)?.data
if (!asset?.asset_kind || script?.runnable_kind !== 'script' || !script.in_pipeline)
return undefined
const target = { kind: asset.asset_kind as AssetKind, path: asset.path as string }
const reads = view.edges.some(
(x) => x.kind === 'lineage-read' && x.source === e.source && x.target === e.target
)
return e.kind === 'lineage-read'
? {
label: 'Rerun on each write',
detail: `${listenChangeText(target)} in ${script.path}; only its annotation header changes. Saved as a draft.`,
run: () => onEdgeAction?.({ action: 'listen', script: script.path, asset: target })
}
: {
label: 'Stop rerunning on writes',
detail: `${stopListeningChangeText(target, reads)} in ${script.path}; only its annotation header changes. Saved as a draft.`,
run: () =>
onEdgeAction?.({ action: 'stop', script: script.path, asset: target, reads })
}
if (!e) return []
const assetData = view.nodes.find((n) => n.id === e.source)?.data
if (!assetData?.asset_kind) return []
const asset = { kind: assetData.asset_kind as AssetKind, path: assetData.path as string }
const scriptIds =
e.kind === 'asset-flow'
? (e.via ?? [])
: e.kind === 'lineage-read' || e.kind === 'trigger-asset'
? [e.target]
: []
const listen: EdgeTarget[] = []
const stop: EdgeTarget[] = []
for (const id of scriptIds) {
const script = model.nodes.find((n) => n.id === id)?.data
if (script?.runnable_kind !== 'script' || !script.in_pipeline) continue
const has = (kind: string) =>
model.edges.some((x) => x.kind === kind && x.source === e.source && x.target === id)
const target = { script: script.path as string, asset, reads: has('lineage-read') }
if (has('trigger-asset')) stop.push(target)
else if (target.reads) listen.push(target)
}
const where = (ts: EdgeTarget[]) =>
ts.length === 1 ? ts[0].script : `${ts.length} scripts: ${ts.map((t) => t.script).join(', ')}`
const out: Array<{ label: string; detail: string; run: () => void }> = []
if (listen.length) {
out.push({
label: 'Rerun on each write',
detail: `${listenChangeText(asset)} in ${where(listen)}; only the annotation header changes. Saved as a draft.`,
run: () => onEdgeAction?.({ action: 'listen', targets: listen })
})
}
if (stop.length) {
out.push({
label: 'Stop rerunning on writes',
detail: `${stopListeningChangeText(asset, stop.some((t) => t.reads))} in ${where(stop)}; only the annotation header changes. Saved as a draft.`,
run: () => onEdgeAction?.({ action: 'stop', targets: stop })
})
}
return out
})
let hoveredEdgeText = $derived.by(() => {
const e = hoveredEdge && view.edges.find((x) => x.id === hoveredEdge!.id)
@@ -1194,10 +1214,19 @@
: 0
})
: undefined
// The scripts and triggers folded into this node, whose errors it reports.
const scriptIds = u ? (u.multiple ? u.runnableIds : [u.runnableId]) : []
const foldedIds = [
...scriptIds,
...m.edges
.filter((e) => e.kind === 'trigger-native' && scriptIds.includes(e.target))
.map((e) => e.source)
]
return {
...data,
upstream,
width,
foldedIds,
...(toDelete
? {
deleteCount: toDelete.trigger ? 2 : 1,
@@ -1391,7 +1420,14 @@
}
}
: n.data
const data = fix ? { ...withUpstream, fix } : withUpstream
const foldedFixes = ((n.data.foldedIds ?? []) as string[])
.map((id) => toFix(nodeFixes?.get(id)))
.filter((f) => f != undefined)
const data = {
...withUpstream,
...(fix ? { fix } : {}),
...(foldedFixes.length ? { foldedFixes } : {})
}
return {
id: n.id,
type: n.type,
@@ -1841,7 +1877,7 @@
{#if hoveredEdge && hoveredEdgeText}
{@const d = hoveredEdgeText}
{@const action = hoveredEdgeAction}
{@const actions = hoveredEdgeActions}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed z-50 max-w-80 rounded-md border bg-surface px-2.5 py-1.5 text-xs text-primary shadow-md"
@@ -1855,13 +1891,13 @@
{d.phrase}
{#if d.object}<span class="font-semibold text-emphasis">{d.object}</span>{/if}
{#if d.note}<div class="mt-0.5 text-2xs text-secondary">{d.note}</div>{/if}
{#if action}
{#each actions as action (action.label)}
<div class="mt-2 flex flex-col gap-0.5">
<Button
variant="default"
unifiedSize="sm"
onclick={() => {
// Closing the popover drops `action`: run what it held.
// Closing the popover drops `actions`: run what this one held.
const run = action.run
hoveredEdge = undefined
run()
@@ -1871,7 +1907,7 @@
</Button>
<span class="text-2xs text-secondary">{action.detail}</span>
</div>
{/if}
{/each}
</div>
{/if}
@@ -152,6 +152,8 @@
upstream?: AssetUpstreamChips
/** What is wrong with the node, and how to fix it: replaces the "+". */
fix?: NodeFix
/** Assets-only: errors of the scripts and triggers folded into this node. */
foldedFixes?: NodeFix[]
/** The assets-only node for the scripts that build no asset. */
noAsset?: boolean
/** Card width in the assets-only view, sized to its text. */
@@ -355,7 +357,14 @@
{/snippet}
{#snippet upstreamRow()}
{#if upstream}
{#if data.foldedFixes?.length}
<!-- A folded script or trigger is misconfigured: its Fix, one at a time. -->
<NodeFixButton
chip
fix={data.foldedFixes[0]}
label={`${data.foldedFixes.length} error${data.foldedFixes.length === 1 ? '' : 's'} · Fix`}
/>
{:else 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',
@@ -39,7 +39,16 @@ red fill already says something is wrong.
import { Button } from '$lib/components/common'
import { Wrench } from 'lucide-svelte'
let { fix }: { fix: NodeFix } = $props()
let {
fix,
label = 'Fix',
chip = false
}: {
fix: NodeFix
label?: string
/** A small chip in a node's text, rather than the pill on its edge. */
chip?: boolean
} = $props()
let open = $state(false)
let anchorEl: HTMLElement | undefined = $state()
@@ -60,12 +69,21 @@ red fill already says something is wrong.
contentClasses="p-3 w-96"
>
{#snippet trigger()}
<span
class="h-6 px-2 rounded-full flex items-center gap-1 bg-surface border border-gray-400 dark:border-gray-600 text-secondary hover:text-primary hover:border-gray-500 shadow-sm text-2xs font-normal leading-none whitespace-nowrap"
>
<Wrench size={12} class="shrink-0" />
Fix
</span>
{#if chip}
<span
class="flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-3xs leading-none font-normal whitespace-nowrap bg-surface border-red-300 dark:border-red-600 text-red-700 dark:text-red-300 hover:bg-surface-hover"
>
<Wrench size={10} class="shrink-0" />
{label}
</span>
{:else}
<span
class="h-6 px-2 rounded-full flex items-center gap-1 bg-surface border border-gray-400 dark:border-gray-600 text-secondary hover:text-primary hover:border-gray-500 shadow-sm text-2xs font-normal leading-none whitespace-nowrap"
>
<Wrench size={12} class="shrink-0" />
{label}
</span>
{/if}
{/snippet}
{#snippet content()}
<div class="flex flex-col gap-3">
@@ -181,9 +181,7 @@
assetDeleteVerb?: 'Delete' | 'Archive'
onEdgeAction?: (a: {
action: 'listen' | 'stop'
script: string
asset: { kind: AssetKind; path: string }
reads?: boolean
targets: Array<{ script: string; asset: { kind: AssetKind; path: string }; reads: boolean }>
}) => void
onRunProducer?: (producer: RunProducer) => Promise<string | undefined>
idlePane?: Snippet
@@ -122,14 +122,20 @@
})
let selected = $state(buildEmptySelected())
let menuOpen = $state(false)
// Opened for a given output: the output is not the user's to pick.
let outputLocked = $state(false)
$effect(() => {
if (!openSignal) return
untrack(() => {
outputLocked = !!presetOutput
if (presetOutput) {
resetWizard()
selected.outputId = presetOutput.kind
config.asset = { ...presetOutput }
config.tableEdited = true
// Named after what it writes, in the characters a script path allows.
const name = presetOutput.table.replace(/[^\w-]+/g, '_').replace(/^_+|_+$/g, '')
if (name) selected.scriptPath = name
}
if (scheduleFor) {
resetWizard()
@@ -206,15 +212,13 @@
// The path grammar of `Path.svelte`: word segments, no empty or trailing ones.
const SCRIPT_NAME_RE = /^[\w-]+(\/[\w-]+)*$/
let scriptNameValid = $derived(SCRIPT_NAME_RE.test(selected.scriptPath.trim()))
let assetValid = $derived(
!!config.asset?.store && TABLE_NAME_RE.test(config.asset.table.trim())
)
let assetValid = $derived(!!config.asset?.store && TABLE_NAME_RE.test(config.asset.table.trim()))
let stepValid = $derived(
currentConfigStep === 'schedule'
? config.validCron &&
!!config.schedule.trim() &&
!config.schedulePathError &&
config.advancedDefaults === 'loaded'
!!config.schedule.trim() &&
!config.schedulePathError &&
config.advancedDefaults === 'loaded'
: currentConfigStep === 'asset'
? assetValid
: scriptNameValid && !!selected.triggerId && !!selected.language && !!selected.outputId
@@ -336,7 +340,14 @@
contentClasses={twMerge(
'p-0 bg-surface overflow-hidden relative transition-height',
// The first step's columns (w-56 + w-48 + w-80), held across steps.
singleKind ? 'w-[32rem]' : 'w-[46rem]',
// Without the output column when the output is preset (w-80 fewer).
singleKind
? outputLocked
? 'w-[12rem]'
: 'w-[32rem]'
: outputLocked
? 'w-[26rem]'
: 'w-[46rem]',
currentConfigStep === 'schedule'
? 'h-[27rem]'
: currentConfigStep === 'asset'
@@ -364,23 +375,23 @@
{/snippet}
{#snippet content({ close })}
<div class="h-full" bind:this={contentEl}>
{#if currentConfigStep}
{@render configStepSection(currentConfigStep, close)}
{:else}
<div class="flex flex-col h-full">
<div class={'flex flex-row transition-height divide-x overflow-y-scroll'}>
{@render topSection()}
{#if currentConfigStep}
{@render configStepSection(currentConfigStep, close)}
{:else}
<div class="flex flex-col h-full">
<div class={'flex flex-row transition-height divide-x overflow-y-scroll'}>
{@render topSection()}
</div>
<div
class={twMerge(
'flex flex-col gap-5 grow transition-height px-4 border-t',
showBottomPanel ? 'h-[14rem] py-4' : 'h-0'
)}
>
{@render bottomSection(close)}
</div>
</div>
<div
class={twMerge(
'flex flex-col gap-5 grow transition-height px-4 border-t',
showBottomPanel ? 'h-[14rem] py-4' : 'h-0'
)}
>
{@render bottomSection(close)}
</div>
</div>
{/if}
{/if}
</div>
{/snippet}
</Popover>
@@ -437,7 +448,9 @@
'flex flex-col gap-1 p-2 overflow-auto transition-opacity w-48',
selected.triggerId ? '' : 'opacity-20'
)}
{@attach arrowTabNav({ onKeyDown: selectAndAdvanceTo(() => outputEl) })}
{@attach arrowTabNav({
onKeyDown: selectAndAdvanceTo(() => (outputLocked ? pathEl : outputEl))
})}
>
<div class="text-2xs font-normal text-secondary ml-2 mb-1">Language</div>
{#each languages as l}
@@ -447,6 +460,9 @@
unifiedSize="sm"
btnClasses="justify-start"
selected={isSelected}
disabled={outputLocked &&
!!selected.outputId &&
!compatibleOutputKinds(l.lang).includes(selected.outputId)}
onClick={() => {
selected.language = l.lang
const _compatibleOutputKinds = compatibleOutputKinds(l.lang)
@@ -461,34 +477,36 @@
{/each}
</div>
<div
bind:this={outputEl}
class={twMerge(
'flex flex-col gap-1 p-2 grow w-80 overflow-auto transition-opacity',
selected.triggerId && selected.language ? '' : 'opacity-20'
)}
{@attach arrowTabNav({ onKeyDown: selectAndAdvanceTo(() => pathEl, { timeout: 50 }) })}
>
<div class="text-2xs font-normal text-secondary ml-2 mb-1">Output asset</div>
{#each visibleOutputKinds.length ? visibleOutputKinds : PIPELINE_OUTPUT_KINDS as k}
{@const isSelected = selected.outputId === k.id}
<Button variant="subtle" selected={isSelected} onClick={() => (selected.outputId = k.id)}>
<span class="flex flex-col items-start flex-1 min-w-0 text-left">
<span class="text-xs font-normal leading-tight">{k.label}</span>
{#if k.description}
<span
class={twMerge(
'text-2xs font-normal leading-snug mt-0.5',
isSelected ? 'text-accent/80' : 'text-hint'
)}
>
{k.description}
</span>
{/if}
</span>
</Button>
{/each}
</div>
{#if !outputLocked}
<div
bind:this={outputEl}
class={twMerge(
'flex flex-col gap-1 p-2 grow w-80 overflow-auto transition-opacity',
selected.triggerId && selected.language ? '' : 'opacity-20'
)}
{@attach arrowTabNav({ onKeyDown: selectAndAdvanceTo(() => pathEl, { timeout: 50 }) })}
>
<div class="text-2xs font-normal text-secondary ml-2 mb-1">Output asset</div>
{#each visibleOutputKinds.length ? visibleOutputKinds : PIPELINE_OUTPUT_KINDS as k}
{@const isSelected = selected.outputId === k.id}
<Button variant="subtle" selected={isSelected} onClick={() => (selected.outputId = k.id)}>
<span class="flex flex-col items-start flex-1 min-w-0 text-left">
<span class="text-xs font-normal leading-tight">{k.label}</span>
{#if k.description}
<span
class={twMerge(
'text-2xs font-normal leading-snug mt-0.5',
isSelected ? 'text-accent/80' : 'text-hint'
)}
>
{k.description}
</span>
{/if}
</span>
</Button>
{/each}
</div>
{/if}
{/snippet}
{#snippet bottomSection(close: () => void)}
@@ -555,14 +573,14 @@
>{scheduleFor
? 'Create schedule'
: !isLastStep
? step === 0
? 'Configure'
: 'Next'
: hasAiPrompt
? 'Generate'
: selectedKind?.configuredAfterCreate
? step === 0
? 'Configure'
: 'Create'}</Button
: 'Next'
: hasAiPrompt
? 'Generate'
: selectedKind?.configuredAfterCreate
? 'Configure'
: 'Create'}</Button
>
{/snippet}
@@ -613,12 +631,12 @@
Saved as a draft schedule and deployed along with the pipeline.
</span>
<div data-autofocus class="contents">
<CronInput
bind:schedule={config.schedule}
bind:timezone={config.timezone}
bind:validCRON={config.validCron}
bind:cronVersion={config.cronVersion}
/>
<CronInput
bind:schedule={config.schedule}
bind:timezone={config.timezone}
bind:validCRON={config.validCron}
bind:cronVersion={config.cronVersion}
/>
</div>
<div data-advanced class="mt-4">
<Section label="Advanced" collapsable initiallyCollapsed>
@@ -1339,6 +1339,23 @@
return true
}
// Edits several scripts' headers at once, after the user has seen the list: a
// change to one edge or node must not silently rewrite scripts they didn't pick.
let multiEdit = $state<{ scripts: string[]; what: string; run: () => Promise<void> } | undefined>(
undefined
)
function editScripts(
scripts: string[],
what: string,
apply: (script: string) => (content: string) => string
) {
const run = async () => {
for (const s of scripts) await editScriptHeader(s, apply(s), what)
}
if (scripts.length > 1) multiEdit = { scripts, what, run }
else void run()
}
const scriptName = (p: string) => p.split('/').pop() ?? p
const isTableKind = (k: AssetKind): k is 'ducklake' | 'datatable' =>
k === 'ducklake' || k === 'datatable'
@@ -1412,15 +1429,12 @@
actions.push({
label: one ? `Stop ${scriptName(one)} running on writes` : 'Stop them running on writes',
detail: `${stopListeningChangeText(asset, reads)} in ${subscribers.join(', ')}; only the annotation header changes. Saved as a draft.`,
run: async () => {
for (const s of subscribers) {
await editScriptHeader(
s,
(c) => stopListeningToAsset(c, asset, readsAsset(s, asset)),
'Stopped running on writes'
)
}
}
run: () =>
editScripts(
subscribers,
'Stopped running on writes',
(s) => (c) => stopListeningToAsset(c, asset, readsAsset(s, asset))
)
})
return actions
}
@@ -3218,15 +3232,29 @@
assetDeleteVerb={canHardDeleteScripts ? 'Delete' : 'Archive'}
onEdgeAction={isOperator
? undefined
: ({ action, script, asset, reads }) =>
editScriptHeader(
script,
(c) =>
action === 'listen'
? listenToAsset(c, asset)
: stopListeningToAsset(c, asset, reads ?? false),
action === 'listen' ? 'Now reruns on each write' : 'Stopped rerunning on writes'
)}
: ({
action,
targets
}: {
action: 'listen' | 'stop'
targets: Array<{
script: string
asset: { kind: AssetKind; path: string }
reads: boolean
}>
}) => {
const byScript = new Map(targets.map((t) => [t.script, t] as const))
editScripts(
[...byScript.keys()],
action === 'listen' ? 'Now reruns on each write' : 'Stopped rerunning on writes',
(s) => (c) => {
const t = byScript.get(s)!
return action === 'listen'
? listenToAsset(c, t.asset)
: stopListeningToAsset(c, t.asset, t.reads)
}
)
}}
onRunProducer={mode === 'edit' ? handleRunProducer : undefined}
onRequestEdit={isOperator ? undefined : () => setMode('edit')}
canRunByPath={openScriptHasDataUpload}
@@ -3356,6 +3384,30 @@
<!-- 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={multiEdit != undefined}
title={`Edit ${multiEdit?.scripts.length ?? 0} scripts?`}
confirmationText="Edit scripts"
type="info"
onConfirmed={() => {
const m = multiEdit
multiEdit = undefined
void m?.run()
}}
onCanceled={() => (multiEdit = undefined)}
>
{#if multiEdit}
<div class="flex flex-col gap-2 text-xs text-secondary">
<p>This will edit the annotation header of the following scripts. Each is saved as a draft.</p>
<ul class="flex flex-col gap-1">
{#each multiEdit.scripts as s (s)}
<li class="text-emphasis truncate">{s}</li>
{/each}
</ul>
</div>
{/if}
</ConfirmationModal>
<ConfirmationModal
open={assetDeleteTarget != undefined}
loading={assetDeleteLoading}