diff --git a/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte b/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte index 6acd9a7e6c..4b53b32fb4 100644 --- a/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte @@ -1385,7 +1385,25 @@ unsaved: d.unsaved as boolean | undefined, onOpen: () => onselect?.({ kind: 'runnable', runnable_kind: d.runnable_kind, path: d.path }) - })) + })), + // Every script's own triggers; asset triggers show as edges instead. + triggers: (u.multiple ? u.runnableIds : []) + .flatMap((id) => { + const d = runnables.get(id)?.data + if (!d) return [] + return m.edges + .filter((e) => e.kind === 'trigger-native' && e.target === id) + .map((e) => m.nodes.find((n) => n.id === e.source)) + .filter((t) => t != undefined) + .map((t) => ({ + ...triggerChip( + { nodeId: t.id, kind: t.data.kind, data: t.data }, + d.path as string + ), + script: d.path as string + })) + }) + .filter((t) => !t.hidden) } : { runnableId: u.runnableId, @@ -1427,16 +1445,25 @@ : 'none' in upstream ? 'External source' : 'multiple' in upstream - ? 'Multiple upstream nodes' + ? (upstream.triggers?.length ?? 0) > 1 + ? 'Multiple triggers' + : (upstream.triggers?.length ?? 0) === 1 + ? (upstream.triggers?.[0]?.label ?? '') + : '' : upstream.trigger.hidden ? '' : `${upstream.trigger.label}${upstream.trigger.draft ? ' · draft' : ''}`, chipIcon: foldedErrors > 0, + // Room for the chip's trailing icon: a pencil, or the chevron of a + // several-trigger menu (about as wide). chipEdit: !foldedErrors && - 'trigger' in upstream && - !!upstream.trigger?.onOpen && - !upstream.trigger.missing, + ('multiple' in upstream + ? (upstream.triggers?.length ?? 0) > 1 || + (!!upstream.triggers?.[0]?.onOpen && !upstream.triggers?.[0]?.missing) + : 'trigger' in upstream && + !!upstream.trigger?.onOpen && + !upstream.trigger.missing), header: 'runnableId' in upstream ? { @@ -1474,6 +1501,28 @@ .map((id) => runnables.get(id)?.data) .filter((d) => d?.runnable_kind === 'script') .map((d) => ({ path: d.path as string, unsaved: !!d.unsaved })), + // Its scripts' own triggers: a row that starts only them, which + // webhooks and data uploads (no row) and missing ones are not. + triggerDeletes: scriptIds.flatMap((id) => { + const scriptPath = runnables.get(id)?.data?.path + return m.edges + .filter((e) => e.kind === 'trigger-native' && e.target === id) + .map((e) => m.nodes.find((n) => n.id === e.source)?.data) + .filter( + (t) => + t && + t.kind !== 'webhook' && + t.kind !== 'data_upload' && + t.ref && + !t.missing && + ((t.runnable_paths ?? []) as string[]).every((p) => p === scriptPath) + ) + .map((t) => ({ + kind: t.kind as NativeTriggerKind, + path: t.ref as string, + draft: !!t.draft + })) + }), deleteVerb: assetDeleteVerb, onDeleteUpstream: (t: AssetUpstreamDelete) => onDeleteAssetUpstream?.(t), // The No-asset card stands for its scripts; an asset is built by them. diff --git a/frontend/src/lib/components/assets/AssetGraph/AssetNode.svelte b/frontend/src/lib/components/assets/AssetGraph/AssetNode.svelte index cd46b701d7..37006afa0b 100644 --- a/frontend/src/lib/components/assets/AssetGraph/AssetNode.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/AssetNode.svelte @@ -45,6 +45,18 @@ // `content` / `language` so the page-level run handler can dispatch to // `runScriptPreview` instead of `runScriptByPath` (which 404s for // non-deployed scripts). + /** What starts a producing script, as its chip shows it. */ + export type TriggerChipData = { + label: string + /** No chip: the node's incoming edges already show what starts it. */ + hidden?: boolean + missing?: boolean + draft?: boolean + /** Opens the trigger's editor; unset for what has none (manual, asset change). */ + onOpen?: () => void + selected?: boolean + } + export type AssetUpstreamChips = | { none: true } | { @@ -58,6 +70,8 @@ unsaved?: boolean onOpen: () => void }> + /** The scripts' own triggers, each named with its script. */ + triggers: Array selected?: boolean } | { @@ -67,16 +81,7 @@ language?: ScriptLang unsaved?: boolean runState?: RunnableRunState - trigger: { - label: string - /** No chip: the node's incoming edges already show what starts it. */ - hidden?: boolean - missing?: boolean - draft?: boolean - /** Opens the trigger's editor; unset for what has none (manual, asset change). */ - onOpen?: () => void - selected?: boolean - } + trigger: TriggerChipData onOpen: () => void selected?: boolean } @@ -183,6 +188,7 @@ /** When no combined delete is possible: the folded scripts, each deletable * on its own. */ scriptDeletes?: Array<{ path: string; unsaved: boolean }> + triggerDeletes?: Array> onDeleteUpstream?: (target: import('./assetsOnlyView').AssetUpstreamDelete) => void deleteVerb?: 'Delete' | 'Archive' /** Why nothing can be deleted, when the producer is shared. */ @@ -236,10 +242,11 @@ let menuItems: Item[] = $derived( data.onDeleteUpstream && data.upstreamDelete ? deleteItems(data.upstreamDelete, data.onDeleteUpstream) - : data.onDeleteUpstream && data.scriptDeletes?.length - ? data.scriptDeletes.map((script) => - deleteItems({ script }, data.onDeleteUpstream!) - ).flat() + : data.onDeleteUpstream && (data.scriptDeletes?.length || data.triggerDeletes?.length) + ? [ + ...(data.scriptDeletes ?? []).map((script) => ({ script })), + ...(data.triggerDeletes ?? []).map((trigger) => ({ trigger })) + ].flatMap((t) => deleteItems(t, data.onDeleteUpstream!)) : data.deleteBlocked ? [ { @@ -305,6 +312,16 @@ upstreamScriptItems(coProducers, (path) => coProducers.find((s) => s.path === path)?.onOpen()) ) let scriptMenuOpen = $state(false) + let coTriggers = $derived(upstream && 'triggers' in upstream ? upstream.triggers : []) + let coTriggerItems: Item[] = $derived( + coTriggers.map((t) => ({ + displayName: `${t.label}${t.draft ? ' · draft' : ''}`, + description: coProducers.length > 1 ? t.script : undefined, + disabled: !t.onOpen, + action: () => t.onOpen?.() + })) + ) + let triggerMenuOpen = $state(false) // dbt badge. `materialized` is dbt's own word rather than the Windmill // strategy because `view` and `ephemeral` have no strategy, and showing the @@ -451,8 +468,40 @@ fix={data.foldedFixes[0]} label={`${data.foldedFixes.length} error${data.foldedFixes.length === 1 ? '' : 's'} · Fix`} /> + {:else if upstream && coTriggers.length > 1} + + +
e.stopPropagation()} + onkeydown={(e) => e.stopPropagation()} + > + + {#snippet buttonReplacement()} + t.selected)) && CHIP_SELECTED + )} + title="Started by several triggers: pick one to edit" + > + Multiple triggers + + + {/snippet} + +
{:else if upstream} - {@const trigger = producer?.trigger} + {@const trigger = producer?.trigger ?? coTriggers[0]} + {@const triggerScript = producer?.path ?? coTriggers[0]?.script} {@const chipClass = twMerge( 'truncate rounded-md border px-1.5 py-1 text-3xs leading-none font-normal', trigger?.missing @@ -467,7 +516,7 @@ type="button" class={twMerge(chipClass, 'flex items-center gap-1 min-w-0')} title={trigger.missing - ? `No ${trigger.label} trigger targets ${producer?.path} yet: click to create one` + ? `No ${trigger.label} trigger targets ${triggerScript} yet: click to create one` : `Edit the ${trigger.label} trigger`} onpointerdown={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} @@ -485,8 +534,6 @@ External source {:else if trigger} {trigger.label}{trigger.draft ? ' · draft' : ''} - {:else} - Multiple upstream nodes {/if} {/if} @@ -541,7 +588,10 @@ : data.path} {selected} tone={data.error ? 'error' : undefined} - subtitle={data.foldedFixes?.length || (upstream && !producer?.trigger?.hidden) + subtitle={data.foldedFixes?.length || + (upstream && + !producer?.trigger?.hidden && + !('triggers' in upstream && upstream.triggers.length === 0)) ? upstreamRow : undefined} header={hasHeader ? scriptHeader : undefined}