mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 08:02:47 +00:00
feat: pipeline node context menus, add-at-cursor, and assets-only delete
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
9f4e508369
commit
001fb8b5a3
@@ -0,0 +1,145 @@
|
||||
<!--
|
||||
@component
|
||||
The menu that adds a pipeline script started by a data source (a schedule, a
|
||||
webhook, a queue…): the add node's, and the canvas's right-click one.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte'
|
||||
import PipelineInsertMenu, {
|
||||
type PipelineInsertOptions,
|
||||
type PipelineInsertPick
|
||||
} from './PipelineInsertMenu.svelte'
|
||||
import {
|
||||
Clock,
|
||||
Webhook,
|
||||
Mail,
|
||||
Zap,
|
||||
Radio,
|
||||
MessageSquare,
|
||||
Database,
|
||||
Send,
|
||||
CloudCog,
|
||||
Upload
|
||||
} from 'lucide-svelte'
|
||||
import type { ScriptLang } from '$lib/gen'
|
||||
import type { NativeTriggerKind } from './types'
|
||||
import { PIPELINE_LANGUAGES } from './pipelineLanguages'
|
||||
import type { PipelineOutputKind } from './pipelineTemplates'
|
||||
|
||||
interface Props {
|
||||
onAddPipelineScript: (
|
||||
language: ScriptLang,
|
||||
path: string,
|
||||
source: { kind: NativeTriggerKind; path: string | undefined },
|
||||
outputKind: PipelineOutputKind,
|
||||
aiPrompt?: string,
|
||||
options?: PipelineInsertOptions
|
||||
) => void
|
||||
pathPrefix: string
|
||||
defaultPathSuffix: string
|
||||
trigger: Snippet<[{ open: boolean }]>
|
||||
/** Opens the menu each time it changes. */
|
||||
openSignal?: number
|
||||
}
|
||||
let { onAddPipelineScript, pathPrefix, defaultPathSuffix, trigger, openSignal }: Props =
|
||||
$props()
|
||||
|
||||
function handlePick(pick: PipelineInsertPick) {
|
||||
if (!pick.language || !pick.path) return
|
||||
// Native trigger annotation is marker-only — the binding lives on the
|
||||
// trigger row's own `script_path`, which is created separately.
|
||||
onAddPipelineScript(
|
||||
pick.language as ScriptLang,
|
||||
pick.path,
|
||||
{ kind: pick.kindId as NativeTriggerKind, path: undefined },
|
||||
(pick.outputKind ?? 'none') as PipelineOutputKind,
|
||||
pick.aiPrompt,
|
||||
{ schedule: pick.schedule, outputAsset: pick.outputAsset }
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<PipelineInsertMenu
|
||||
kinds={[
|
||||
{
|
||||
id: 'data_upload',
|
||||
label: 'On data upload',
|
||||
description: 'UI-first: run by uploading a file via the S3 picker',
|
||||
icon: Upload
|
||||
},
|
||||
{
|
||||
id: 'schedule',
|
||||
label: 'On schedule',
|
||||
description: 'Triggered by a schedule you create',
|
||||
icon: Clock
|
||||
},
|
||||
{
|
||||
id: 'webhook',
|
||||
label: 'On webhook',
|
||||
description: 'Triggered by an HTTP webhook',
|
||||
icon: Webhook
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
configuredAfterCreate: true,
|
||||
label: 'On email',
|
||||
description: 'Triggered by incoming email',
|
||||
icon: Mail
|
||||
},
|
||||
{
|
||||
id: 'kafka',
|
||||
configuredAfterCreate: true,
|
||||
label: 'On Kafka',
|
||||
description: 'Triggered by a Kafka message',
|
||||
icon: Zap
|
||||
},
|
||||
{
|
||||
id: 'mqtt',
|
||||
configuredAfterCreate: true,
|
||||
label: 'On MQTT',
|
||||
description: 'Triggered by an MQTT message',
|
||||
icon: Radio
|
||||
},
|
||||
{
|
||||
id: 'amqp',
|
||||
configuredAfterCreate: true,
|
||||
label: 'On AMQP',
|
||||
description: 'Triggered by an AMQP (RabbitMQ) message',
|
||||
icon: Radio
|
||||
},
|
||||
{
|
||||
id: 'nats',
|
||||
configuredAfterCreate: true,
|
||||
label: 'On NATS',
|
||||
description: 'Triggered by a NATS message',
|
||||
icon: MessageSquare
|
||||
},
|
||||
{
|
||||
id: 'postgres',
|
||||
configuredAfterCreate: true,
|
||||
label: 'On Postgres',
|
||||
description: 'Triggered by a Postgres event',
|
||||
icon: Database
|
||||
},
|
||||
{
|
||||
id: 'sqs',
|
||||
configuredAfterCreate: true,
|
||||
label: 'On SQS',
|
||||
description: 'Triggered by an SQS message',
|
||||
icon: Send
|
||||
},
|
||||
{
|
||||
id: 'gcp',
|
||||
configuredAfterCreate: true,
|
||||
label: 'On GCP Pub/Sub',
|
||||
description: 'Triggered by a Pub/Sub message',
|
||||
icon: CloudCog
|
||||
}
|
||||
]}
|
||||
languages={PIPELINE_LANGUAGES as any}
|
||||
{pathPrefix}
|
||||
{defaultPathSuffix}
|
||||
onPick={handlePick}
|
||||
{openSignal}
|
||||
{trigger}
|
||||
/>
|
||||
@@ -1,160 +1,36 @@
|
||||
<script lang="ts">
|
||||
import PipelineInsertMenu, { type PipelineInsertPick } from './PipelineInsertMenu.svelte'
|
||||
import {
|
||||
Plus,
|
||||
Clock,
|
||||
Webhook,
|
||||
Mail,
|
||||
Zap,
|
||||
Radio,
|
||||
MessageSquare,
|
||||
Database,
|
||||
Send,
|
||||
CloudCog,
|
||||
Upload
|
||||
} from 'lucide-svelte'
|
||||
import type { ScriptLang } from '$lib/gen'
|
||||
import type { NativeTriggerKind } from './types'
|
||||
import { PIPELINE_LANGUAGES } from './pipelineLanguages'
|
||||
import AddDataSourceMenu from './AddDataSourceMenu.svelte'
|
||||
import { Plus } from 'lucide-svelte'
|
||||
import { NODE } from '$lib/components/graph/util'
|
||||
import type { PipelineOutputKind } from './pipelineTemplates'
|
||||
|
||||
// Each left-column kind is just "pipeline script triggered by <trigger
|
||||
// source>". id === the SCRIPT_TRIGGER_KIND value, so the handler can
|
||||
// dispatch on it uniformly. Asset-triggered scripts are not in this
|
||||
// menu; those live under the per-asset + inside the graph.
|
||||
type KindId = NativeTriggerKind
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
onAddPipelineScript: (
|
||||
language: ScriptLang,
|
||||
path: string,
|
||||
source: { kind: NativeTriggerKind; path: string | undefined },
|
||||
outputKind: PipelineOutputKind,
|
||||
aiPrompt?: string,
|
||||
options?: import('./PipelineInsertMenu.svelte').PipelineInsertOptions
|
||||
) => void
|
||||
pathPrefix: string
|
||||
defaultPathSuffix: string
|
||||
}
|
||||
data: Pick<
|
||||
import('svelte').ComponentProps<typeof AddDataSourceMenu>,
|
||||
'onAddPipelineScript' | 'pathPrefix' | 'defaultPathSuffix'
|
||||
>
|
||||
}
|
||||
let { data }: Props = $props()
|
||||
|
||||
function handlePick(pick: PipelineInsertPick) {
|
||||
if (!pick.language || !pick.path) return
|
||||
const kindId = pick.kindId as KindId
|
||||
const outputKind = (pick.outputKind ?? 'none') as PipelineOutputKind
|
||||
// Native trigger annotation is marker-only — the binding lives on
|
||||
// the trigger row's own `script_path`, which the user creates
|
||||
// separately. Seed with `path: undefined`.
|
||||
data.onAddPipelineScript(
|
||||
pick.language as ScriptLang,
|
||||
pick.path,
|
||||
{ kind: kindId, path: undefined },
|
||||
outputKind,
|
||||
pick.aiPrompt,
|
||||
{ schedule: pick.schedule, outputAsset: pick.outputAsset }
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- The layout gives this node a full node-width slot; centering the pill in
|
||||
it lines it up with the nodes below whatever its label's width. -->
|
||||
<div class="flex justify-center" style="width: {NODE.width}px;">
|
||||
<PipelineInsertMenu
|
||||
kinds={[
|
||||
{
|
||||
id: 'data_upload',
|
||||
label: 'On data upload',
|
||||
description: 'UI-first: run by uploading a file via the S3 picker',
|
||||
icon: Upload
|
||||
},
|
||||
{
|
||||
id: 'schedule',
|
||||
label: 'On schedule',
|
||||
description: 'Triggered by a schedule you create',
|
||||
icon: Clock
|
||||
},
|
||||
{
|
||||
id: 'webhook',
|
||||
label: 'On webhook',
|
||||
description: 'Triggered by an HTTP webhook',
|
||||
icon: Webhook
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
configuredAfterCreate: true,
|
||||
label: 'On email',
|
||||
description: 'Triggered by incoming email',
|
||||
icon: Mail
|
||||
},
|
||||
{
|
||||
id: 'kafka',
|
||||
configuredAfterCreate: true,
|
||||
label: 'On Kafka',
|
||||
description: 'Triggered by a Kafka message',
|
||||
icon: Zap
|
||||
},
|
||||
{
|
||||
id: 'mqtt',
|
||||
configuredAfterCreate: true,
|
||||
label: 'On MQTT',
|
||||
description: 'Triggered by an MQTT message',
|
||||
icon: Radio
|
||||
},
|
||||
{
|
||||
id: 'amqp',
|
||||
configuredAfterCreate: true,
|
||||
label: 'On AMQP',
|
||||
description: 'Triggered by an AMQP (RabbitMQ) message',
|
||||
icon: Radio
|
||||
},
|
||||
{
|
||||
id: 'nats',
|
||||
configuredAfterCreate: true,
|
||||
label: 'On NATS',
|
||||
description: 'Triggered by a NATS message',
|
||||
icon: MessageSquare
|
||||
},
|
||||
{
|
||||
id: 'postgres',
|
||||
configuredAfterCreate: true,
|
||||
label: 'On Postgres',
|
||||
description: 'Triggered by a Postgres event',
|
||||
icon: Database
|
||||
},
|
||||
{
|
||||
id: 'sqs',
|
||||
configuredAfterCreate: true,
|
||||
label: 'On SQS',
|
||||
description: 'Triggered by an SQS message',
|
||||
icon: Send
|
||||
},
|
||||
{
|
||||
id: 'gcp',
|
||||
configuredAfterCreate: true,
|
||||
label: 'On GCP Pub/Sub',
|
||||
description: 'Triggered by a Pub/Sub message',
|
||||
icon: CloudCog
|
||||
}
|
||||
]}
|
||||
languages={PIPELINE_LANGUAGES as any}
|
||||
pathPrefix={data.pathPrefix}
|
||||
defaultPathSuffix={data.defaultPathSuffix}
|
||||
onPick={handlePick}
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<!-- Quiet insert affordance, mirroring the flow editor's inline +
|
||||
buttons (bg-surface + gray border + secondary text) — a filled
|
||||
accent pill would outweigh every real node on the canvas. -->
|
||||
<button
|
||||
type="button"
|
||||
class="h-8 px-3 rounded-full flex items-center gap-1.5 whitespace-nowrap bg-surface border border-gray-400 dark:border-gray-600 text-xs font-normal text-secondary shadow-sm hover:bg-surface-hover transition-colors cursor-pointer"
|
||||
>
|
||||
<Plus size={16} class="shrink-0" />
|
||||
Add data source
|
||||
</button>
|
||||
{/snippet}
|
||||
</PipelineInsertMenu>
|
||||
<AddDataSourceMenu
|
||||
onAddPipelineScript={data.onAddPipelineScript}
|
||||
pathPrefix={data.pathPrefix}
|
||||
defaultPathSuffix={data.defaultPathSuffix}
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<!-- Quiet insert affordance, mirroring the flow editor's inline +
|
||||
buttons (bg-surface + gray border + secondary text) — a filled
|
||||
accent pill would outweigh every real node on the canvas. -->
|
||||
<button
|
||||
type="button"
|
||||
class="h-8 px-3 rounded-full flex items-center gap-1.5 whitespace-nowrap bg-surface border border-gray-400 dark:border-gray-600 text-xs font-normal text-secondary shadow-sm hover:bg-surface-hover transition-colors cursor-pointer"
|
||||
>
|
||||
<Plus size={16} class="shrink-0" />
|
||||
Add data source
|
||||
</button>
|
||||
{/snippet}
|
||||
</AddDataSourceMenu>
|
||||
</div>
|
||||
|
||||
@@ -14,17 +14,30 @@
|
||||
import RunnableNode from './RunnableNode.svelte'
|
||||
import TriggerNode, { type TriggerNodeKind } from './TriggerNode.svelte'
|
||||
import AddNode from './AddNode.svelte'
|
||||
import AddDataSourceMenu from './AddDataSourceMenu.svelte'
|
||||
import DataTestNode from './DataTestNode.svelte'
|
||||
import AssetGraphEdge from './AssetGraphEdge.svelte'
|
||||
import PanToNode from './PanToNode.svelte'
|
||||
import InitialFitView from './InitialFitView.svelte'
|
||||
import { layoutAssetGraph, PIPELINE_NODE_EXTRA_ROW } from './assetGraphLayout'
|
||||
import { assetsOnlyView, type AssetUpstream } from './assetsOnlyView'
|
||||
import {
|
||||
assetsOnlyView,
|
||||
upstreamDeletion,
|
||||
type AssetUpstream,
|
||||
type AssetUpstreamDelete
|
||||
} from './assetsOnlyView'
|
||||
import { assetsOnlyNodeWidth } from './assetNodeWidth'
|
||||
import { formatAssetKind, formatShortAssetPath } from '$lib/components/assets/lib'
|
||||
import { TRIGGER_NODE_STYLE } from './TriggerNode.svelte'
|
||||
import { describeSchedule } from '$lib/utils/describeCron'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import { fly } from 'svelte/transition'
|
||||
import { Plus } from 'lucide-svelte'
|
||||
import {
|
||||
getContextMenuContainerClass,
|
||||
CONTEXT_MENU_ITEM_BASE_CLASS,
|
||||
CONTEXT_MENU_ITEM_HOVER_CLASS
|
||||
} from '$lib/components/common/contextmenu/contextMenuStyles'
|
||||
import { computeMutedReadKeys, dbtAssociations } from './resolveGraph'
|
||||
import { buildDownstreamMap } from './graphTraversal'
|
||||
import { buildLineageDownstreamMap } from './boundedCascade'
|
||||
@@ -200,6 +213,9 @@
|
||||
/** Offer the "Assets only" switch, which folds scripts and triggers into the
|
||||
* assets they produce. */
|
||||
assetsOnlyToggle?: boolean
|
||||
/** Deletes what produces an asset in the assets-only view: its script, and its
|
||||
* trigger when set. The canvas only offers what no other asset shares. */
|
||||
onDeleteAssetUpstream?: (target: AssetUpstreamDelete) => void
|
||||
}
|
||||
let {
|
||||
graph,
|
||||
@@ -233,11 +249,30 @@
|
||||
recomputedAssetIds,
|
||||
assetRunStatus,
|
||||
scrollZoom = true,
|
||||
assetsOnlyToggle = false
|
||||
assetsOnlyToggle = false,
|
||||
onDeleteAssetUpstream
|
||||
}: Props = $props()
|
||||
|
||||
let assetsOnly = $state(false)
|
||||
|
||||
// Right-click on the empty canvas: a menu at the pointer whose entry opens the
|
||||
// add-data-source menu there too.
|
||||
let paneMenu = $state<{ x: number; y: number } | undefined>(undefined)
|
||||
let addMenuAt = $state<{ x: number; y: number }>({ x: 0, y: 0 })
|
||||
let addMenuSignal = $state(0)
|
||||
function handleContextMenu(e: MouseEvent) {
|
||||
const t = e.target as HTMLElement | null
|
||||
// A node's own actions menu opens on right-click; the browser's never does,
|
||||
// on nodes without one either.
|
||||
if (t?.closest('.svelte-flow__node')) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if (!onAddPipelineScript || boundPick || !t?.closest('.svelte-flow__pane')) return
|
||||
e.preventDefault()
|
||||
paneMenu = { x: e.clientX, y: e.clientY }
|
||||
}
|
||||
|
||||
// `${kind}:${path}` ids for the hovered / pinned runs (both script and flow
|
||||
// variants, since the run row's kind isn't known here).
|
||||
const runIds = (paths: string[] | undefined): Set<string> =>
|
||||
@@ -859,6 +894,7 @@
|
||||
timezone: info.timezone,
|
||||
summary: info.summary,
|
||||
runnable_path: info.runnable_path,
|
||||
runnable_paths: info.runnable_paths,
|
||||
// data_upload nodes go green once a file is staged for their
|
||||
// target script (see readyDataUploadPaths / page dataUploadArgs).
|
||||
ready: info.runnable_path
|
||||
@@ -994,6 +1030,10 @@
|
||||
path: r.path
|
||||
})
|
||||
}
|
||||
const toDelete =
|
||||
onDeleteAssetUpstream && u && !u.multiple && r
|
||||
? upstreamDeletion(u, r, v.upstream)
|
||||
: undefined
|
||||
const asset = { kind: n.data.asset_kind, path: n.data.path }
|
||||
// Chips this estimate does not model keep the regular width.
|
||||
const width =
|
||||
@@ -1015,7 +1055,20 @@
|
||||
scriptChip: 'runnableId' in upstream
|
||||
})
|
||||
: undefined
|
||||
return { ...n, data: { ...n.data, upstream, width } }
|
||||
return {
|
||||
...n,
|
||||
data: {
|
||||
...n.data,
|
||||
upstream,
|
||||
width,
|
||||
...(toDelete
|
||||
? {
|
||||
deleteCount: toDelete.trigger ? 2 : 1,
|
||||
onDeleteUpstream: () => onDeleteAssetUpstream?.(toDelete)
|
||||
}
|
||||
: {})
|
||||
}
|
||||
}
|
||||
})
|
||||
const edges: BuiltEdge[] = v.edges.map((e) => ({ ...e, kind: e.kind as BuiltEdge['kind'] }))
|
||||
if (nodes.some((n) => n.id === ADD_NODE_ID)) {
|
||||
@@ -1507,7 +1560,12 @@
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
||||
<div class="w-full h-full relative" bind:clientWidth={paneWidth} onclickcapture={handleBackgroundClick}>
|
||||
<div
|
||||
class="w-full h-full relative"
|
||||
bind:clientWidth={paneWidth}
|
||||
onclickcapture={handleBackgroundClick}
|
||||
oncontextmenu={handleContextMenu}
|
||||
>
|
||||
<SvelteFlow
|
||||
{nodes}
|
||||
{edges}
|
||||
@@ -1570,6 +1628,55 @@
|
||||
</SvelteFlow>
|
||||
</div>
|
||||
|
||||
<svelte:window
|
||||
onpointerdown={(e) => {
|
||||
if (paneMenu && !(e.target as HTMLElement | null)?.closest('[data-pane-menu]'))
|
||||
paneMenu = undefined
|
||||
}}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Escape') paneMenu = undefined
|
||||
}}
|
||||
/>
|
||||
|
||||
{#if paneMenu}
|
||||
<div
|
||||
class="fixed {getContextMenuContainerClass()}"
|
||||
style="left: {paneMenu.x}px; top: {paneMenu.y}px;"
|
||||
transition:fly={{ duration: 150, y: -10 }}
|
||||
role="menu"
|
||||
data-pane-menu
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
class="{CONTEXT_MENU_ITEM_BASE_CLASS} {CONTEXT_MENU_ITEM_HOVER_CLASS} gap-2 w-full text-left"
|
||||
onclick={() => {
|
||||
if (paneMenu) addMenuAt = paneMenu
|
||||
paneMenu = undefined
|
||||
addMenuSignal++
|
||||
}}
|
||||
>
|
||||
<Plus size={14} />
|
||||
Add data source
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if onAddPipelineScript}
|
||||
<div class="fixed w-0 h-0" style="left: {addMenuAt.x}px; top: {addMenuAt.y}px;">
|
||||
<AddDataSourceMenu
|
||||
{onAddPipelineScript}
|
||||
{pathPrefix}
|
||||
{defaultPathSuffix}
|
||||
openSignal={addMenuSignal}
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<span class="block w-0 h-0" aria-hidden="true"></span>
|
||||
{/snippet}
|
||||
</AddDataSourceMenu>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style lang="postcss">
|
||||
:global(.svelte-flow__handle) {
|
||||
opacity: 0;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import AssetGenericIcon from '$lib/components/icons/AssetGenericIcon.svelte'
|
||||
import { formatAssetKind, formatShortAssetPath, type AssetKind } from '$lib/components/assets/lib'
|
||||
import PipelineNodeCard from './PipelineNodeCard.svelte'
|
||||
import NodeActionsMenu from './NodeActionsMenu.svelte'
|
||||
import PipelineInsertMenu, { type PipelineInsertPick } from './PipelineInsertMenu.svelte'
|
||||
import {
|
||||
ArrowUpRight,
|
||||
@@ -16,11 +17,12 @@
|
||||
ShieldCheck,
|
||||
ShieldAlert,
|
||||
CheckCircle2,
|
||||
Trash2,
|
||||
XCircle
|
||||
} from 'lucide-svelte'
|
||||
import type { ScriptLang } from '$lib/gen'
|
||||
import { enterpriseLicense } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/utils'
|
||||
import { sendUserToast, type Item } from '$lib/utils'
|
||||
import { PIPELINE_LANGUAGES } from './pipelineLanguages'
|
||||
import type { PipelineOutputKind } from './pipelineTemplates'
|
||||
import type { DbtAssetProvenance } from './types'
|
||||
@@ -134,6 +136,10 @@
|
||||
upstream?: AssetUpstreamChips
|
||||
/** Card width in the assets-only view, sized to its text. */
|
||||
width?: number
|
||||
/** Assets-only view: deletes the producing script and trigger no other
|
||||
* asset shares; `deleteCount` is how many that is. */
|
||||
onDeleteUpstream?: () => void
|
||||
deleteCount?: number
|
||||
}
|
||||
// SvelteFlow injects this on the node component when the user clicks
|
||||
// the node. Combined with our own `hovered` state to drive the
|
||||
@@ -189,6 +195,18 @@
|
||||
}
|
||||
|
||||
let showAdd = $derived(data.onAddScript != undefined)
|
||||
let menuItems: Item[] = $derived(
|
||||
data.onDeleteUpstream
|
||||
? [
|
||||
{
|
||||
displayName: `Delete ${data.deleteCount} item${data.deleteCount === 1 ? '' : 's'}`,
|
||||
icon: Trash2,
|
||||
type: 'delete' as const,
|
||||
action: () => data.onDeleteUpstream?.()
|
||||
}
|
||||
]
|
||||
: []
|
||||
)
|
||||
let upstream = $derived(data.upstream)
|
||||
const CHIP_SELECTED =
|
||||
'bg-surface-accent-selected border-border-selected hover:bg-surface-accent-selected text-accent'
|
||||
@@ -318,156 +336,158 @@
|
||||
<div class="wm-recompute-flash pointer-events-none absolute inset-0 z-10 rounded-md"></div>
|
||||
{/key}
|
||||
{/if}
|
||||
<PipelineNodeCard
|
||||
kindLabel={formatAssetKind(asset)}
|
||||
title={formatShortAssetPath(asset)}
|
||||
tooltip={data.error ? `${data.path}: ${data.error}` : data.path}
|
||||
{selected}
|
||||
tone={data.error ? 'error' : undefined}
|
||||
subtitle={upstream ? upstreamRow : undefined}
|
||||
width={data.width}
|
||||
surface={upstream ? 'primary' : 'secondary'}
|
||||
>
|
||||
{#snippet icon()}
|
||||
<!-- Data identity carries the accent (luminance blue), pairing with
|
||||
the blue write edges that produce these assets — scripts stay
|
||||
neutral, so script vs data reads at a glance. -->
|
||||
<AssetGenericIcon
|
||||
assetKind={data.asset_kind}
|
||||
class={data.error
|
||||
? 'text-red-600 dark:text-red-200'
|
||||
: selected
|
||||
? 'text-accent'
|
||||
: 'text-blue-600 dark:text-blue-400'}
|
||||
size="14px"
|
||||
/>
|
||||
{/snippet}
|
||||
{#snippet leading()}
|
||||
{#if data.runStatus}
|
||||
<!-- The run in view, per relation: a spinner while its producer is
|
||||
building it, then its outcome. Left of the name so the eye finds
|
||||
the moving nodes first on a wide graph. -->
|
||||
<span
|
||||
class="shrink-0 mr-1 {data.runStatus === 'failed'
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: data.runStatus === 'materialized'
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-blue-600 dark:text-blue-400'}"
|
||||
title={data.runStatus}
|
||||
>
|
||||
{#if data.runStatus === 'running'}
|
||||
<Loader2 size={11} class="animate-spin" />
|
||||
{:else if data.runStatus === 'failed'}
|
||||
<XCircle size={11} />
|
||||
{:else}
|
||||
<CheckCircle2 size={11} />
|
||||
{/if}
|
||||
</span>
|
||||
<!-- Rows the run wrote. Recorded per relation already, and the cheapest
|
||||
answer to "did this model actually produce anything" — a model that
|
||||
built green but emitted 0 rows is the failure that looks like a
|
||||
success. Only once settled: mid-build the number is not yet real. -->
|
||||
{#if data.runStatus === 'materialized' && data.runRowCount != undefined}
|
||||
<NodeActionsMenu items={menuItems} hover={hovered}>
|
||||
<PipelineNodeCard
|
||||
kindLabel={formatAssetKind(asset)}
|
||||
title={formatShortAssetPath(asset)}
|
||||
tooltip={data.error ? `${data.path}: ${data.error}` : data.path}
|
||||
{selected}
|
||||
tone={data.error ? 'error' : undefined}
|
||||
subtitle={upstream ? upstreamRow : undefined}
|
||||
width={data.width}
|
||||
surface={upstream ? 'primary' : 'secondary'}
|
||||
>
|
||||
{#snippet icon()}
|
||||
<!-- Data identity carries the accent (luminance blue), pairing with
|
||||
the blue write edges that produce these assets — scripts stay
|
||||
neutral, so script vs data reads at a glance. -->
|
||||
<AssetGenericIcon
|
||||
assetKind={data.asset_kind}
|
||||
class={data.error
|
||||
? 'text-red-600 dark:text-red-200'
|
||||
: selected
|
||||
? 'text-accent'
|
||||
: 'text-blue-600 dark:text-blue-400'}
|
||||
size="14px"
|
||||
/>
|
||||
{/snippet}
|
||||
{#snippet leading()}
|
||||
{#if data.runStatus}
|
||||
<!-- The run in view, per relation: a spinner while its producer is
|
||||
building it, then its outcome. Left of the name so the eye finds
|
||||
the moving nodes first on a wide graph. -->
|
||||
<span
|
||||
class="shrink-0 mr-1 text-3xs tabular-nums text-tertiary"
|
||||
title="{data.runRowCount} rows written by this run"
|
||||
class="shrink-0 mr-1 {data.runStatus === 'failed'
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: data.runStatus === 'materialized'
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-blue-600 dark:text-blue-400'}"
|
||||
title={data.runStatus}
|
||||
>
|
||||
{Intl.NumberFormat().format(data.runRowCount)}
|
||||
{#if data.runStatus === 'running'}
|
||||
<Loader2 size={11} class="animate-spin" />
|
||||
{:else if data.runStatus === 'failed'}
|
||||
<XCircle size={11} />
|
||||
{:else}
|
||||
<CheckCircle2 size={11} />
|
||||
{/if}
|
||||
</span>
|
||||
<!-- Rows the run wrote. Recorded per relation already, and the cheapest
|
||||
answer to "did this model actually produce anything" — a model that
|
||||
built green but emitted 0 rows is the failure that looks like a
|
||||
success. Only once settled: mid-build the number is not yet real. -->
|
||||
{#if data.runStatus === 'materialized' && data.runRowCount != undefined}
|
||||
<span
|
||||
class="shrink-0 mr-1 text-3xs tabular-nums text-tertiary"
|
||||
title="{data.runRowCount} rows written by this run"
|
||||
>
|
||||
{Intl.NumberFormat().format(data.runRowCount)}
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet trailing()}
|
||||
<!-- Fork data-environment chip: in a fork every asset shares its parent's
|
||||
name, so the env it resolves to must read at a glance. Labeled + tinted
|
||||
(amber "parent" = deferred read of the parent's current table via a
|
||||
view; emerald "fork" = the fork's own materialized copy) rather than a
|
||||
bare icon, which was too easy to miss. The title carries the detail. -->
|
||||
{#if data.fork_materialization === 'deferred'}
|
||||
<span
|
||||
class="shrink-0 mr-1.5 flex items-center gap-0.5 rounded px-1 py-px text-3xs font-semibold uppercase tracking-wide bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300 border border-amber-300 dark:border-amber-700"
|
||||
title="Deferred to parent workspace: reads the parent's current data. Materialize it in this fork to iterate on it."
|
||||
>
|
||||
<ArrowUpRight size={10} />
|
||||
parent
|
||||
</span>
|
||||
{:else if data.fork_materialization === 'fork'}
|
||||
<span
|
||||
class="shrink-0 mr-1.5 flex items-center gap-0.5 rounded px-1 py-px text-3xs font-semibold uppercase tracking-wide bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-300 border border-emerald-300 dark:border-emerald-700"
|
||||
title="Materialized in this fork: reads and writes use the fork's isolated copy."
|
||||
>
|
||||
<GitFork size={10} />
|
||||
fork
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet trailing()}
|
||||
<!-- Fork data-environment chip: in a fork every asset shares its parent's
|
||||
name, so the env it resolves to must read at a glance. Labeled + tinted
|
||||
(amber "parent" = deferred read of the parent's current table via a
|
||||
view; emerald "fork" = the fork's own materialized copy) rather than a
|
||||
bare icon, which was too easy to miss. The title carries the detail. -->
|
||||
{#if data.fork_materialization === 'deferred'}
|
||||
<span
|
||||
class="shrink-0 mr-1.5 flex items-center gap-0.5 rounded px-1 py-px text-3xs font-semibold uppercase tracking-wide bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300 border border-amber-300 dark:border-amber-700"
|
||||
title="Deferred to parent workspace: reads the parent's current data. Materialize it in this fork to iterate on it."
|
||||
>
|
||||
<ArrowUpRight size={10} />
|
||||
parent
|
||||
</span>
|
||||
{:else if data.fork_materialization === 'fork'}
|
||||
<span
|
||||
class="shrink-0 mr-1.5 flex items-center gap-0.5 rounded px-1 py-px text-3xs font-semibold uppercase tracking-wide bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-300 border border-emerald-300 dark:border-emerald-700"
|
||||
title="Materialized in this fork: reads and writes use the fork's isolated copy."
|
||||
>
|
||||
<GitFork size={10} />
|
||||
fork
|
||||
</span>
|
||||
{/if}
|
||||
<!-- dbt chip: names the materialization dbt declares, plus a test count
|
||||
when the model carries generic tests. Orange keeps it visually
|
||||
separate from the fork/SCD2 chips, which describe Windmill state.
|
||||
Where the project node is on the graph it also stands in for the edge
|
||||
to it: hovering lights that node up, clicking selects it. Where it is
|
||||
not — the run page, and a pipeline holding a dbt project — there is
|
||||
nothing to point at, so the chip renders inert. -->
|
||||
{#if data.dbt}
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 mr-1.5 flex items-center gap-0.5 rounded px-1 py-px text-3xs font-semibold tracking-wide bg-orange-100 dark:bg-orange-900/40 text-orange-700 dark:text-orange-300 border border-orange-300 dark:border-orange-700 {data.onDbtSelect
|
||||
? 'hover:brightness-95 cursor-pointer'
|
||||
: 'cursor-default'}"
|
||||
title={dbtTitle}
|
||||
onmouseenter={() => data.onDbtHover?.(true)}
|
||||
onmouseleave={() => data.onDbtHover?.(false)}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation()
|
||||
data.onDbtSelect?.()
|
||||
}}
|
||||
>
|
||||
<DbtIcon width={9} height={9} />
|
||||
{dbtLabel}
|
||||
{#if data.dbt.data_tests?.length}
|
||||
<span class="opacity-70">· {data.dbt.data_tests.length}T</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
<!-- SCD2 companion marker: this node is the `<dim>_current` "latest row
|
||||
per key" view its producer maintains alongside the base dimension.
|
||||
Icon-only (the pill already truncates); the title names the base. -->
|
||||
{#if data.derived_from}
|
||||
<span
|
||||
class="shrink-0 mr-1.5 text-violet-600 dark:text-violet-400"
|
||||
title={`SCD2 current view of ${data.derived_from}: latest row per key, maintained by the same producer as the base dimension.`}
|
||||
>
|
||||
<History size={12} />
|
||||
</span>
|
||||
{/if}
|
||||
{#if producer}
|
||||
{#if producer.runState}
|
||||
<RunStateChip runState={producer.runState} class="mr-1 h-5 px-1.5 rounded-md" />
|
||||
<!-- dbt chip: names the materialization dbt declares, plus a test count
|
||||
when the model carries generic tests. Orange keeps it visually
|
||||
separate from the fork/SCD2 chips, which describe Windmill state.
|
||||
Where the project node is on the graph it also stands in for the edge
|
||||
to it: hovering lights that node up, clicking selects it. Where it is
|
||||
not — the run page, and a pipeline holding a dbt project — there is
|
||||
nothing to point at, so the chip renders inert. -->
|
||||
{#if data.dbt}
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 mr-1.5 flex items-center gap-0.5 rounded px-1 py-px text-3xs font-semibold tracking-wide bg-orange-100 dark:bg-orange-900/40 text-orange-700 dark:text-orange-300 border border-orange-300 dark:border-orange-700 {data.onDbtSelect
|
||||
? 'hover:brightness-95 cursor-pointer'
|
||||
: 'cursor-default'}"
|
||||
title={dbtTitle}
|
||||
onmouseenter={() => data.onDbtHover?.(true)}
|
||||
onmouseleave={() => data.onDbtHover?.(false)}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation()
|
||||
data.onDbtSelect?.()
|
||||
}}
|
||||
>
|
||||
<DbtIcon width={9} height={9} />
|
||||
{dbtLabel}
|
||||
{#if data.dbt.data_tests?.length}
|
||||
<span class="opacity-70">· {data.dbt.data_tests.length}T</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class={twMerge(
|
||||
'shrink-0 h-5 min-w-5 px-1 grid place-items-center rounded-md border bg-surface text-secondary hover:bg-surface-hover border-gray-300 dark:border-gray-600',
|
||||
producer.unsaved && 'border-dashed border-gray-400 dark:border-gray-500',
|
||||
producer.selected && CHIP_SELECTED
|
||||
)}
|
||||
title={`Open ${producer.summary ? `${producer.summary} (${producer.path})` : producer.path}${producer.unsaved ? ' · draft' : ''}`}
|
||||
aria-label="Open the script that produces this asset"
|
||||
onpointerdown={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation()
|
||||
producer?.onOpen()
|
||||
}}
|
||||
>
|
||||
{#if producer.language}
|
||||
<LanguageIcon lang={producer.language} width={12} height={12} />
|
||||
{:else}
|
||||
<Code2 size={12} />
|
||||
<!-- SCD2 companion marker: this node is the `<dim>_current` "latest row
|
||||
per key" view its producer maintains alongside the base dimension.
|
||||
Icon-only (the pill already truncates); the title names the base. -->
|
||||
{#if data.derived_from}
|
||||
<span
|
||||
class="shrink-0 mr-1.5 text-violet-600 dark:text-violet-400"
|
||||
title={`SCD2 current view of ${data.derived_from}: latest row per key, maintained by the same producer as the base dimension.`}
|
||||
>
|
||||
<History size={12} />
|
||||
</span>
|
||||
{/if}
|
||||
{#if producer}
|
||||
{#if producer.runState}
|
||||
<RunStateChip runState={producer.runState} class="mr-1 h-5 px-1.5 rounded-md" />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</PipelineNodeCard>
|
||||
<button
|
||||
type="button"
|
||||
class={twMerge(
|
||||
'shrink-0 h-5 min-w-5 px-1 grid place-items-center rounded-md border bg-surface text-secondary hover:bg-surface-hover border-gray-300 dark:border-gray-600',
|
||||
producer.unsaved && 'border-dashed border-gray-400 dark:border-gray-500',
|
||||
producer.selected && CHIP_SELECTED
|
||||
)}
|
||||
title={`Open ${producer.summary ? `${producer.summary} (${producer.path})` : producer.path}${producer.unsaved ? ' · draft' : ''}`}
|
||||
aria-label="Open the script that produces this asset"
|
||||
onpointerdown={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation()
|
||||
producer?.onOpen()
|
||||
}}
|
||||
>
|
||||
{#if producer.language}
|
||||
<LanguageIcon lang={producer.language} width={12} height={12} />
|
||||
{:else}
|
||||
<Code2 size={12} />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</PipelineNodeCard>
|
||||
</NodeActionsMenu>
|
||||
{#if showGuardBadge}
|
||||
<!-- Data-test outcome badge. Floats off the TOP-RIGHT corner (opposite the
|
||||
left-edge run button and the bottom + inserter) so it never collides
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<!--
|
||||
@component
|
||||
A pipeline node's actions, reachable two ways: the kebab revealed on hover at the
|
||||
node's top-right corner, and a right-click menu over the node carrying the same
|
||||
items. Place it inside the node's `relative` wrapper, around the node's card.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { EllipsisVertical } from 'lucide-svelte'
|
||||
import { stopPropagation, preventDefault } from 'svelte/legacy'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import ContextMenu from '$lib/components/common/contextmenu/ContextMenu.svelte'
|
||||
import { contextMenuItemsFromMenu } from '$lib/components/common/contextmenu/fromMenuItems'
|
||||
import type { Item } from '$lib/utils'
|
||||
|
||||
interface Props {
|
||||
items: Item[]
|
||||
/** The pointer is over the node: shows the kebab. */
|
||||
hover: boolean
|
||||
children: Snippet
|
||||
}
|
||||
|
||||
let { items, hover, children }: Props = $props()
|
||||
|
||||
let menuOpen = $state(false)
|
||||
let contextItems = $derived(contextMenuItemsFromMenu(items))
|
||||
</script>
|
||||
|
||||
{#if items.length > 0}
|
||||
<ContextMenu items={contextItems}>
|
||||
{@render children()}
|
||||
</ContextMenu>
|
||||
<!-- Rendered only on hover or while open, so the canvas stays clean at rest.
|
||||
`pointerdown` is stopped so svelte-flow doesn't select the node when the
|
||||
user reaches for the menu. -->
|
||||
<div class="absolute -top-2 -right-2 h-7 p-1 min-w-7" style="will-change: transform;">
|
||||
<DropdownV2
|
||||
{items}
|
||||
placement="bottom-end"
|
||||
bind:open={menuOpen}
|
||||
fixedHeight={false}
|
||||
usePointerDownOutside
|
||||
>
|
||||
{#snippet buttonReplacement()}
|
||||
<button
|
||||
class={twMerge(
|
||||
'center-center p-1 text-secondary shadow-sm bg-surface duration-0 hover:bg-surface-tertiary',
|
||||
hover || menuOpen ? 'block' : '!hidden',
|
||||
'shadow-md rounded-md'
|
||||
)}
|
||||
onpointerdown={stopPropagation(preventDefault(() => {}))}
|
||||
title="Actions"
|
||||
>
|
||||
<EllipsisVertical size={12} />
|
||||
</button>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
</div>
|
||||
{:else}
|
||||
{@render children()}
|
||||
{/if}
|
||||
@@ -11,6 +11,7 @@
|
||||
import type { ColumnLineageGraph } from './columnLineageGraph'
|
||||
import HideButton from '$lib/components/apps/editor/settingsPanel/HideButton.svelte'
|
||||
import AssetGraphCanvas from './AssetGraphCanvas.svelte'
|
||||
import type { AssetUpstreamDelete } from './assetsOnlyView'
|
||||
import AssetGraphDetailsPane from './AssetGraphDetailsPane.svelte'
|
||||
import PipelineEventLog from './PipelineEventLog.svelte'
|
||||
import type {
|
||||
@@ -67,6 +68,7 @@
|
||||
onAddScriptForAsset,
|
||||
onAddPipelineScript,
|
||||
onRunnableMenuRemove,
|
||||
onDeleteAssetUpstream,
|
||||
onRunProducer,
|
||||
idlePane,
|
||||
onRequestEdit,
|
||||
@@ -167,6 +169,7 @@
|
||||
options?: import('./PipelineInsertMenu.svelte').PipelineInsertOptions
|
||||
) => void
|
||||
onRunnableMenuRemove?: (...args: any[]) => void
|
||||
onDeleteAssetUpstream?: (target: AssetUpstreamDelete) => void
|
||||
onRunProducer?: (producer: RunProducer) => Promise<string | undefined>
|
||||
idlePane?: Snippet
|
||||
onRequestEdit?: () => void
|
||||
@@ -518,6 +521,7 @@
|
||||
{onAddScriptForAsset}
|
||||
{onAddPipelineScript}
|
||||
{onRunnableMenuRemove}
|
||||
{onDeleteAssetUpstream}
|
||||
{onRunProducer}
|
||||
{validStartPaths}
|
||||
{onStartBoundedRun}
|
||||
|
||||
@@ -85,6 +85,8 @@
|
||||
/** Receives whether the menu is open, e.g. to keep a hover state while it is. */
|
||||
trigger: import('svelte').Snippet<[{ open: boolean }]>
|
||||
placement?: 'bottom' | 'top' | 'left' | 'right'
|
||||
/** Opens the menu each time it changes, for an entry point other than the trigger. */
|
||||
openSignal?: number
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -94,7 +96,8 @@
|
||||
trigger: triggerSnippet,
|
||||
placement = 'bottom',
|
||||
defaultPathSuffix,
|
||||
onPick
|
||||
onPick,
|
||||
openSignal
|
||||
}: Props = $props()
|
||||
|
||||
// When there's only one trigger kind, hide the Trigger column entirely
|
||||
@@ -110,6 +113,9 @@
|
||||
})
|
||||
let selected = $state(buildEmptySelected())
|
||||
let menuOpen = $state(false)
|
||||
$effect(() => {
|
||||
if (openSignal) menuOpen = true
|
||||
})
|
||||
let selectedKind = $derived(kinds.find((k) => k.id === selected.triggerId))
|
||||
|
||||
// Steps after the first, each configuring something the picks call for.
|
||||
|
||||
@@ -3,18 +3,8 @@
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
|
||||
import type { NativeTriggerKind, PipelineTriggerDraftKind } from './types'
|
||||
import {
|
||||
EmailTriggerService,
|
||||
GcpTriggerService,
|
||||
KafkaTriggerService,
|
||||
MqttTriggerService,
|
||||
AmqpTriggerService,
|
||||
NatsTriggerService,
|
||||
PostgresTriggerService,
|
||||
ScheduleService,
|
||||
SqsTriggerService,
|
||||
type Schedule
|
||||
} from '$lib/gen'
|
||||
import type { Schedule } from '$lib/gen'
|
||||
import { deleteTriggerRow } from './pipelineTriggerDraftDeploy'
|
||||
import KafkaTriggerEditor from '$lib/components/triggers/kafka/KafkaTriggerEditor.svelte'
|
||||
import MqttTriggerEditor from '$lib/components/triggers/mqtt/MqttTriggerEditor.svelte'
|
||||
import AmqpTriggerEditor from '$lib/components/triggers/amqp/AmqpTriggerEditor.svelte'
|
||||
@@ -189,37 +179,7 @@
|
||||
const { kind, path: triggerPath } = triggerDeleteTarget
|
||||
triggerDeleteLoading = true
|
||||
try {
|
||||
switch (kind) {
|
||||
case 'schedule':
|
||||
await ScheduleService.deleteSchedule({ workspace, path: triggerPath })
|
||||
break
|
||||
case 'kafka':
|
||||
await KafkaTriggerService.deleteKafkaTrigger({ workspace, path: triggerPath })
|
||||
break
|
||||
case 'mqtt':
|
||||
await MqttTriggerService.deleteMqttTrigger({ workspace, path: triggerPath })
|
||||
break
|
||||
case 'amqp':
|
||||
await AmqpTriggerService.deleteAmqpTrigger({ workspace, path: triggerPath })
|
||||
break
|
||||
case 'nats':
|
||||
await NatsTriggerService.deleteNatsTrigger({ workspace, path: triggerPath })
|
||||
break
|
||||
case 'postgres':
|
||||
await PostgresTriggerService.deletePostgresTrigger({ workspace, path: triggerPath })
|
||||
break
|
||||
case 'sqs':
|
||||
await SqsTriggerService.deleteSqsTrigger({ workspace, path: triggerPath })
|
||||
break
|
||||
case 'gcp':
|
||||
await GcpTriggerService.deleteGcpTrigger({ workspace, path: triggerPath })
|
||||
break
|
||||
case 'email':
|
||||
await EmailTriggerService.deleteEmailTrigger({ workspace, path: triggerPath })
|
||||
break
|
||||
default:
|
||||
return
|
||||
}
|
||||
if (!(await deleteTriggerRow(kind, triggerPath, workspace))) return
|
||||
sendUserToast(`Deleted ${kind} trigger "${triggerPath}"`)
|
||||
triggerDeleteTarget = undefined
|
||||
onUpdate()
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import {
|
||||
ChevronDown,
|
||||
Code2,
|
||||
EllipsisVertical,
|
||||
GitBranch,
|
||||
Layers,
|
||||
Loader2,
|
||||
@@ -18,15 +17,14 @@
|
||||
} from 'lucide-svelte'
|
||||
import DbtIcon from '$lib/components/icons/DbtIcon.svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { preventDefault, stopPropagation } from 'svelte/legacy'
|
||||
import type { GraphUsageKind } from './types'
|
||||
import type { RunnableRunState } from './activeRunnables.svelte'
|
||||
import { parseDurationSecs } from './parsePipelineAnnotations'
|
||||
import LanguageIcon from '$lib/components/common/languageIcons/LanguageIcon.svelte'
|
||||
import PipelineNodeCard from './PipelineNodeCard.svelte'
|
||||
import NodeActionsMenu from './NodeActionsMenu.svelte'
|
||||
import RunStateChip from './RunStateChip.svelte'
|
||||
import type { ScriptLang } from '$lib/gen'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import Popover from '$lib/components/meltComponents/Popover.svelte'
|
||||
import type { Item } from '$lib/utils'
|
||||
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||||
@@ -120,7 +118,6 @@
|
||||
)
|
||||
|
||||
let hover = $state(false)
|
||||
let menuOpen = $state(false)
|
||||
let running = $state(false)
|
||||
|
||||
// Amber "computing now" surface, gated so only the replay player lights it up.
|
||||
@@ -223,101 +220,103 @@
|
||||
selected state, dashes for unsaved drafts — 1px, like the trigger
|
||||
nodes' dashed outline, so every draft node reads the same.
|
||||
-->
|
||||
<PipelineNodeCard
|
||||
surface="primary"
|
||||
kindLabel="{kindLabel}{data.unsaved ? ' · draft' : ''}"
|
||||
title={data.summary || data.path}
|
||||
tooltip={data.error ? `${data.path}: ${data.error}` : nodeTooltip}
|
||||
{selected}
|
||||
draft={data.unsaved}
|
||||
tone={computingNow ? 'running' : data.error ? 'error' : undefined}
|
||||
>
|
||||
{#snippet icon()}
|
||||
{#if data.language && data.runnable_kind === 'script'}
|
||||
<LanguageIcon lang={data.language} width={14} height={14} />
|
||||
{:else}
|
||||
<Icon size={14} class={selected ? 'text-accent' : 'text-secondary'} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet trailing()}
|
||||
<!-- Annotation chips share one neutral treatment — the icon carries
|
||||
the meaning (colors are reserved for feedback, per the brand
|
||||
guidelines). Only the freshness chip (when it has a verdict)
|
||||
and the run-state chip below use semantic colors. -->
|
||||
{#if data.partition_kind}
|
||||
<div
|
||||
class="shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm bg-surface-secondary text-secondary"
|
||||
title={`// partitioned ${data.partition_kind}`}
|
||||
>
|
||||
<Layers size={10} />
|
||||
<span class="text-3xs leading-none">{data.partition_kind}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Freshness is the one annotation chip that carries feedback (a
|
||||
fresh/stale verdict against real run history), so like the
|
||||
run-state chip it uses semantic colors: emerald = within window,
|
||||
amber = stale. Neutral when there's no verdict (drafts, bad
|
||||
window value). -->
|
||||
{#if data.freshness}
|
||||
<div
|
||||
class={twMerge(
|
||||
'shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm',
|
||||
freshnessState === 'fresh'
|
||||
? 'bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-300'
|
||||
: freshnessState === 'stale'
|
||||
? 'bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300'
|
||||
: 'bg-surface-secondary text-secondary'
|
||||
)}
|
||||
title={freshnessTooltip}
|
||||
>
|
||||
<Timer size={10} />
|
||||
<span class="text-3xs leading-none">{data.freshness}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if data.tag}
|
||||
<div
|
||||
class="shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm bg-surface-secondary text-secondary"
|
||||
title={`// tag ${data.tag}`}
|
||||
>
|
||||
<Tag size={10} />
|
||||
<span class="text-3xs leading-none">{data.tag}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if data.retry}
|
||||
{@const r = data.retry}
|
||||
<div
|
||||
class="shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm bg-surface-secondary text-secondary"
|
||||
title={`// retry ${r.count}${r.delay ? ` ${r.delay}` : ''}`}
|
||||
>
|
||||
<RotateCw size={10} />
|
||||
<span class="text-3xs leading-none">×{r.count}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if data.macros && data.macros.length > 0}
|
||||
<div
|
||||
class="shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm bg-surface-secondary text-secondary"
|
||||
title={`// macros — defines ${data.macros.length} macro${data.macros.length > 1 ? 's' : ''}:\n${data.macros
|
||||
.map((m) => `• ${m.name}(${m.params})${m.is_table ? ' → table' : ''}`)
|
||||
.join('\n')}`}
|
||||
>
|
||||
<SquareFunction size={10} />
|
||||
<span class="text-3xs leading-none">×{data.macros.length}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if data.dbt}
|
||||
<div
|
||||
class="shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm bg-orange-100 dark:bg-orange-900/40 text-orange-700 dark:text-orange-300"
|
||||
title={`dbt project — materializes ${data.dbt.model_count} model${data.dbt.model_count === 1 ? '' : 's'}`}
|
||||
>
|
||||
<DbtIcon width={10} height={10} />
|
||||
<span class="text-3xs leading-none">×{data.dbt.model_count}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if data.runState}
|
||||
<RunStateChip runState={data.runState} class="mr-1" />
|
||||
{/if}
|
||||
{/snippet}
|
||||
</PipelineNodeCard>
|
||||
<NodeActionsMenu items={menuItems} {hover}>
|
||||
<PipelineNodeCard
|
||||
surface="primary"
|
||||
kindLabel="{kindLabel}{data.unsaved ? ' · draft' : ''}"
|
||||
title={data.summary || data.path}
|
||||
tooltip={data.error ? `${data.path}: ${data.error}` : nodeTooltip}
|
||||
{selected}
|
||||
draft={data.unsaved}
|
||||
tone={computingNow ? 'running' : data.error ? 'error' : undefined}
|
||||
>
|
||||
{#snippet icon()}
|
||||
{#if data.language && data.runnable_kind === 'script'}
|
||||
<LanguageIcon lang={data.language} width={14} height={14} />
|
||||
{:else}
|
||||
<Icon size={14} class={selected ? 'text-accent' : 'text-secondary'} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet trailing()}
|
||||
<!-- Annotation chips share one neutral treatment — the icon carries
|
||||
the meaning (colors are reserved for feedback, per the brand
|
||||
guidelines). Only the freshness chip (when it has a verdict)
|
||||
and the run-state chip below use semantic colors. -->
|
||||
{#if data.partition_kind}
|
||||
<div
|
||||
class="shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm bg-surface-secondary text-secondary"
|
||||
title={`// partitioned ${data.partition_kind}`}
|
||||
>
|
||||
<Layers size={10} />
|
||||
<span class="text-3xs leading-none">{data.partition_kind}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Freshness is the one annotation chip that carries feedback (a
|
||||
fresh/stale verdict against real run history), so like the
|
||||
run-state chip it uses semantic colors: emerald = within window,
|
||||
amber = stale. Neutral when there's no verdict (drafts, bad
|
||||
window value). -->
|
||||
{#if data.freshness}
|
||||
<div
|
||||
class={twMerge(
|
||||
'shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm',
|
||||
freshnessState === 'fresh'
|
||||
? 'bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-300'
|
||||
: freshnessState === 'stale'
|
||||
? 'bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300'
|
||||
: 'bg-surface-secondary text-secondary'
|
||||
)}
|
||||
title={freshnessTooltip}
|
||||
>
|
||||
<Timer size={10} />
|
||||
<span class="text-3xs leading-none">{data.freshness}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if data.tag}
|
||||
<div
|
||||
class="shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm bg-surface-secondary text-secondary"
|
||||
title={`// tag ${data.tag}`}
|
||||
>
|
||||
<Tag size={10} />
|
||||
<span class="text-3xs leading-none">{data.tag}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if data.retry}
|
||||
{@const r = data.retry}
|
||||
<div
|
||||
class="shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm bg-surface-secondary text-secondary"
|
||||
title={`// retry ${r.count}${r.delay ? ` ${r.delay}` : ''}`}
|
||||
>
|
||||
<RotateCw size={10} />
|
||||
<span class="text-3xs leading-none">×{r.count}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if data.macros && data.macros.length > 0}
|
||||
<div
|
||||
class="shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm bg-surface-secondary text-secondary"
|
||||
title={`// macros — defines ${data.macros.length} macro${data.macros.length > 1 ? 's' : ''}:\n${data.macros
|
||||
.map((m) => `• ${m.name}(${m.params})${m.is_table ? ' → table' : ''}`)
|
||||
.join('\n')}`}
|
||||
>
|
||||
<SquareFunction size={10} />
|
||||
<span class="text-3xs leading-none">×{data.macros.length}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if data.dbt}
|
||||
<div
|
||||
class="shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm bg-orange-100 dark:bg-orange-900/40 text-orange-700 dark:text-orange-300"
|
||||
title={`dbt project — materializes ${data.dbt.model_count} model${data.dbt.model_count === 1 ? '' : 's'}`}
|
||||
>
|
||||
<DbtIcon width={10} height={10} />
|
||||
<span class="text-3xs leading-none">×{data.dbt.model_count}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if data.runState}
|
||||
<RunStateChip runState={data.runState} class="mr-1" />
|
||||
{/if}
|
||||
{/snippet}
|
||||
</PipelineNodeCard>
|
||||
</NodeActionsMenu>
|
||||
{#if showRun}
|
||||
<!-- Run button revealed on hover/select. Matches the placement, size,
|
||||
and behaviour of AssetNode's run button so both nodes feel
|
||||
@@ -443,39 +442,6 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if menuItems.length > 0}
|
||||
<!--
|
||||
Hover-revealed action menu, top-right of the node. Mirrors the
|
||||
FlowModuleSchemaItem pattern: position the trigger button just
|
||||
outside the node frame, only render it on hover (or while the
|
||||
menu is open) so the canvas isn't visually cluttered when idle.
|
||||
The pointerdown stop+preventDefault keeps xyflow from selecting
|
||||
the node when the user is reaching for the menu.
|
||||
-->
|
||||
<div class="absolute -top-2 -right-2 h-7 p-1 min-w-7" style="will-change: transform;">
|
||||
<DropdownV2
|
||||
items={menuItems}
|
||||
placement="bottom-end"
|
||||
bind:open={menuOpen}
|
||||
fixedHeight={false}
|
||||
usePointerDownOutside
|
||||
>
|
||||
{#snippet buttonReplacement()}
|
||||
<button
|
||||
class={twMerge(
|
||||
'center-center p-1 text-secondary shadow-sm bg-surface duration-0 hover:bg-surface-tertiary',
|
||||
hover || menuOpen ? 'block' : '!hidden',
|
||||
'shadow-md rounded-md'
|
||||
)}
|
||||
onpointerdown={stopPropagation(preventDefault(() => {}))}
|
||||
title="Actions"
|
||||
>
|
||||
<EllipsisVertical size={12} />
|
||||
</button>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Handle type="target" position={Position.Top} isConnectable={false} />
|
||||
|
||||
@@ -50,11 +50,9 @@
|
||||
<script lang="ts">
|
||||
import { Handle, Position } from '@xyflow/svelte'
|
||||
import PipelineNodeCard from './PipelineNodeCard.svelte'
|
||||
import NodeActionsMenu from './NodeActionsMenu.svelte'
|
||||
import { describeSchedule } from '$lib/utils/describeCron'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { AlertTriangle, CheckCircle2, EllipsisVertical, Target, Trash2 } from 'lucide-svelte'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import { stopPropagation, preventDefault } from 'svelte/legacy'
|
||||
import { AlertTriangle, CheckCircle2, Target, Trash2 } from 'lucide-svelte'
|
||||
import type { Item } from '$lib/utils'
|
||||
|
||||
interface Props {
|
||||
@@ -123,7 +121,6 @@
|
||||
let { data }: Props = $props()
|
||||
|
||||
let hover = $state(false)
|
||||
let menuOpen = $state(false)
|
||||
|
||||
let style = $derived(TRIGGER_NODE_STYLE[data.kind])
|
||||
// Webhooks are never genuinely "missing" — every deployed runnable has an
|
||||
@@ -299,60 +296,31 @@
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="relative" onmouseenter={() => (hover = true)} onmouseleave={() => (hover = false)}>
|
||||
<PipelineNodeCard
|
||||
kindLabel={card.kindLabel}
|
||||
title={card.title}
|
||||
tooltip={card.tooltip}
|
||||
draft={card.draft}
|
||||
tone={card.tone}
|
||||
onclick={card.onclick}
|
||||
>
|
||||
{#snippet icon()}
|
||||
{#if canOpenDataUpload}
|
||||
<DataUploadIcon
|
||||
size={14}
|
||||
class={dataUploadReady ? 'text-green-600 dark:text-green-400' : style.iconText}
|
||||
/>
|
||||
{:else}
|
||||
<Icon
|
||||
size={14}
|
||||
class={displayMissing || canCreate ? 'text-red-600 dark:text-red-400' : style.iconText}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</PipelineNodeCard>
|
||||
<NodeActionsMenu items={menuItems} {hover}>
|
||||
<PipelineNodeCard
|
||||
kindLabel={card.kindLabel}
|
||||
title={card.title}
|
||||
tooltip={card.tooltip}
|
||||
draft={card.draft}
|
||||
tone={card.tone}
|
||||
onclick={card.onclick}
|
||||
>
|
||||
{#snippet icon()}
|
||||
{#if canOpenDataUpload}
|
||||
<DataUploadIcon
|
||||
size={14}
|
||||
class={dataUploadReady ? 'text-green-600 dark:text-green-400' : style.iconText}
|
||||
/>
|
||||
{:else}
|
||||
<Icon
|
||||
size={14}
|
||||
class={displayMissing || canCreate ? 'text-red-600 dark:text-red-400' : style.iconText}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</PipelineNodeCard>
|
||||
</NodeActionsMenu>
|
||||
|
||||
{#if menuItems.length > 0}
|
||||
<!-- Hover-revealed kebab menu (Delete only for now). Mirrors the
|
||||
RunnableNode pattern: positioned just outside the top-right of
|
||||
the node, rendered only on hover or while the menu is open so
|
||||
the canvas stays clean at rest. `pointerdown` is stopped so
|
||||
svelte-flow doesn't kick off node selection / drag when the
|
||||
user reaches for the menu. -->
|
||||
<div class="absolute -top-2 -right-2 h-7 p-1 min-w-7" style="will-change: transform;">
|
||||
<DropdownV2
|
||||
items={menuItems}
|
||||
placement="bottom-end"
|
||||
bind:open={menuOpen}
|
||||
fixedHeight={false}
|
||||
usePointerDownOutside
|
||||
>
|
||||
{#snippet buttonReplacement()}
|
||||
<button
|
||||
class={twMerge(
|
||||
'center-center p-1 text-secondary shadow-sm bg-surface duration-0 hover:bg-surface-tertiary',
|
||||
hover || menuOpen ? 'block' : '!hidden',
|
||||
'shadow-md rounded-md'
|
||||
)}
|
||||
onpointerdown={stopPropagation(preventDefault(() => {}))}
|
||||
title="Actions"
|
||||
>
|
||||
<EllipsisVertical size={12} />
|
||||
</button>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Handle type="source" position={Position.Bottom} isConnectable={false} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { assetsOnlyView } from './assetsOnlyView'
|
||||
import { assetsOnlyView, upstreamDeletion, type AssetUpstream } from './assetsOnlyView'
|
||||
|
||||
const node = (id: string, type: string, data: any = {}) => ({ id, type, data })
|
||||
const edge = (source: string, target: string, kind: string) => ({
|
||||
@@ -54,3 +54,39 @@ describe('assetsOnlyView', () => {
|
||||
expect(v.upstream.get('asset:ducklake:report')).toEqual({ multiple: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('upstreamDeletion', () => {
|
||||
const script = { runnable_kind: 'script', path: 'f/p/a' }
|
||||
const schedule = (targets: string[]) => ({
|
||||
kind: 'schedule',
|
||||
nodeId: 'trigger:schedule:f/p/a_schedule',
|
||||
data: { ref: 'f/p/a_schedule', runnable_paths: targets }
|
||||
})
|
||||
const own = (trigger?: any): AssetUpstream & { multiple: false } => ({
|
||||
multiple: false,
|
||||
runnableId: 'script:f/p/a',
|
||||
trigger
|
||||
})
|
||||
|
||||
it('deletes the script and a trigger that fires only it', () => {
|
||||
const u = own(schedule(['f/p/a']))
|
||||
expect(upstreamDeletion(u, script, new Map([['asset:x', u]]))).toEqual({
|
||||
script: { path: 'f/p/a', unsaved: false },
|
||||
trigger: { kind: 'schedule', path: 'f/p/a_schedule', draft: false }
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a trigger that fires other scripts', () => {
|
||||
const u = own(schedule(['f/p/a', 'f/p/b']))
|
||||
expect(upstreamDeletion(u, script, new Map([['asset:x', u]]))?.trigger).toBeUndefined()
|
||||
})
|
||||
|
||||
it('deletes nothing when the script builds another asset too', () => {
|
||||
const u = own(schedule(['f/p/a']))
|
||||
const all = new Map([
|
||||
['asset:x', u],
|
||||
['asset:y', u]
|
||||
])
|
||||
expect(upstreamDeletion(u, script, all)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { NativeTriggerKind } from './types'
|
||||
|
||||
/** A node or edge of the canvas model, reduced to what the assets-only view reads. */
|
||||
export type ViewNode = { id: string; type: string; data: any }
|
||||
export type ViewEdge = {
|
||||
@@ -8,6 +10,12 @@ export type ViewEdge = {
|
||||
unsaved?: boolean
|
||||
}
|
||||
|
||||
/** The script and trigger an assets-only asset's delete removes. */
|
||||
export type AssetUpstreamDelete = {
|
||||
script: { path: string; unsaved: boolean }
|
||||
trigger?: { kind: NativeTriggerKind; path: string; draft: boolean }
|
||||
}
|
||||
|
||||
/** What an asset shows of the script and trigger that produce it, in the assets-only view. */
|
||||
export type AssetUpstream =
|
||||
| { multiple: true }
|
||||
@@ -100,3 +108,31 @@ export function assetsOnlyView<N extends ViewNode, E extends ViewEdge>(
|
||||
const nodeIds = new Set(nodes.filter((n) => n.type === 'asset').map((n) => n.id))
|
||||
return { nodeIds, edges: [...out.values()], upstream }
|
||||
}
|
||||
|
||||
/** What deleting an asset's upstream removes: the script unless another asset
|
||||
* is also built by it, and with it the trigger unless that fires other scripts.
|
||||
* Undefined when nothing can go. */
|
||||
export function upstreamDeletion(
|
||||
u: AssetUpstream & { multiple: false },
|
||||
r: { runnable_kind: string; path: string; unsaved?: boolean },
|
||||
all: Map<string, AssetUpstream>
|
||||
): AssetUpstreamDelete | undefined {
|
||||
if (r.runnable_kind !== 'script') return undefined
|
||||
const producesOthers =
|
||||
[...all.values()].filter((o) => !o.multiple && o.runnableId === u.runnableId).length > 1
|
||||
if (producesOthers) return undefined
|
||||
const t = u.trigger
|
||||
const kind = t?.kind as NativeTriggerKind | undefined
|
||||
const targets: string[] = t?.data?.runnable_paths ?? []
|
||||
const ownTrigger =
|
||||
kind &&
|
||||
kind !== 'webhook' &&
|
||||
kind !== 'data_upload' &&
|
||||
t?.data?.ref &&
|
||||
!t.data.missing &&
|
||||
targets.every((p) => p === r.path)
|
||||
return {
|
||||
script: { path: r.path, unsaved: !!r.unsaved },
|
||||
trigger: ownTrigger ? { kind, path: t.data.ref, draft: !!t.data.draft } : undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,18 @@ import { saveNatsTriggerFromCfg } from '$lib/components/triggers/nats/utils'
|
||||
import { savePostgresTriggerFromCfg } from '$lib/components/triggers/postgres/utils'
|
||||
import { saveSqsTriggerFromCfg } from '$lib/components/triggers/sqs/utils'
|
||||
import { saveGcpTriggerFromCfg } from '$lib/components/triggers/gcp/utils'
|
||||
import type { PipelineTriggerDraft } from './types'
|
||||
import {
|
||||
AmqpTriggerService,
|
||||
EmailTriggerService,
|
||||
GcpTriggerService,
|
||||
KafkaTriggerService,
|
||||
MqttTriggerService,
|
||||
NatsTriggerService,
|
||||
PostgresTriggerService,
|
||||
ScheduleService,
|
||||
SqsTriggerService
|
||||
} from '$lib/gen'
|
||||
import type { NativeTriggerKind, PipelineTriggerDraft } from './types'
|
||||
|
||||
/** Creates the trigger through the same helper its editor saves with; that helper
|
||||
* reports the outcome as a toast and resolves to whether it succeeded. */
|
||||
@@ -36,3 +47,43 @@ export function deployTriggerDraft(d: PipelineTriggerDraft, workspace: string):
|
||||
return saveGcpTriggerFromCfg('', cfg, false, workspace, usedTriggerKinds)
|
||||
}
|
||||
}
|
||||
|
||||
/** Deletes a deployed trigger row. False for the kinds that have none (webhook,
|
||||
* data upload). */
|
||||
export async function deleteTriggerRow(
|
||||
kind: NativeTriggerKind,
|
||||
path: string,
|
||||
workspace: string
|
||||
): Promise<boolean> {
|
||||
switch (kind) {
|
||||
case 'schedule':
|
||||
await ScheduleService.deleteSchedule({ workspace, path })
|
||||
return true
|
||||
case 'kafka':
|
||||
await KafkaTriggerService.deleteKafkaTrigger({ workspace, path })
|
||||
return true
|
||||
case 'mqtt':
|
||||
await MqttTriggerService.deleteMqttTrigger({ workspace, path })
|
||||
return true
|
||||
case 'amqp':
|
||||
await AmqpTriggerService.deleteAmqpTrigger({ workspace, path })
|
||||
return true
|
||||
case 'nats':
|
||||
await NatsTriggerService.deleteNatsTrigger({ workspace, path })
|
||||
return true
|
||||
case 'postgres':
|
||||
await PostgresTriggerService.deletePostgresTrigger({ workspace, path })
|
||||
return true
|
||||
case 'sqs':
|
||||
await SqsTriggerService.deleteSqsTrigger({ workspace, path })
|
||||
return true
|
||||
case 'gcp':
|
||||
await GcpTriggerService.deleteGcpTrigger({ workspace, path })
|
||||
return true
|
||||
case 'email':
|
||||
await EmailTriggerService.deleteEmailTrigger({ workspace, path })
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Item } from '$lib/utils'
|
||||
import type { ContextMenuItem } from './ContextMenu.svelte'
|
||||
|
||||
/** A dropdown's items as the same entries of a right-click menu. */
|
||||
export function contextMenuItemsFromMenu(items: Item[]): ContextMenuItem[] {
|
||||
return items.flatMap((item) => [
|
||||
...(item.separatorTop ? [{ id: `${item.displayName}-divider`, label: '', divider: true }] : []),
|
||||
{
|
||||
id: item.displayName,
|
||||
label: item.displayName,
|
||||
icon: item.icon,
|
||||
disabled: item.disabled,
|
||||
type: item.type,
|
||||
shortcut: item.shortcut,
|
||||
onClick: item.action as (() => void) | undefined
|
||||
}
|
||||
])
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Handle, Position } from '@xyflow/svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import ContextMenu, { type ContextMenuItem } from '../../../common/contextmenu/ContextMenu.svelte'
|
||||
import { contextMenuItemsFromMenu } from '../../../common/contextmenu/fromMenuItems'
|
||||
import { getGraphContext } from '../../graphContext'
|
||||
import type { Item } from '$lib/utils'
|
||||
|
||||
@@ -28,21 +29,7 @@
|
||||
}: Props = $props()
|
||||
|
||||
let resolvedContextMenuItems: ContextMenuItem[] | undefined = $derived(
|
||||
contextMenuItems ??
|
||||
menuItems?.flatMap((item) => [
|
||||
...(item.separatorTop
|
||||
? [{ id: `${item.displayName}-divider`, label: '', divider: true }]
|
||||
: []),
|
||||
{
|
||||
id: item.displayName,
|
||||
label: item.displayName,
|
||||
icon: item.icon,
|
||||
disabled: item.disabled,
|
||||
type: item.type,
|
||||
shortcut: item.shortcut,
|
||||
onClick: item.action as (() => void) | undefined
|
||||
}
|
||||
])
|
||||
contextMenuItems ?? (menuItems && contextMenuItemsFromMenu(menuItems))
|
||||
)
|
||||
|
||||
// NodeWrapper is reused outside the flow graph (e.g. the app decision-tree
|
||||
|
||||
@@ -93,7 +93,11 @@
|
||||
isDraftableTriggerKind,
|
||||
triggerDraftKey
|
||||
} from '$lib/components/assets/AssetGraph/pipelineTriggerDrafts'
|
||||
import { deployTriggerDraft } from '$lib/components/assets/AssetGraph/pipelineTriggerDraftDeploy'
|
||||
import {
|
||||
deleteTriggerRow,
|
||||
deployTriggerDraft
|
||||
} from '$lib/components/assets/AssetGraph/pipelineTriggerDraftDeploy'
|
||||
import type { AssetUpstreamDelete } from '$lib/components/assets/AssetGraph/assetsOnlyView'
|
||||
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
|
||||
import type { PipelineInsertOptions } from '$lib/components/assets/AssetGraph/PipelineInsertMenu.svelte'
|
||||
import {
|
||||
@@ -664,6 +668,8 @@
|
||||
// are removed from the drafts map as they land.
|
||||
let savingAll = $state(false)
|
||||
let saveErrors = $state<Map<string, string>>(new Map())
|
||||
// Opened by a save that fails, so the reason is on screen without a click.
|
||||
let saveErrorsOpen = $state(false)
|
||||
|
||||
// Post-deploy verification: capture the cascade-relevant facts (writes +
|
||||
// `// on` subscriptions) the resolved draft graph promises for `paths`.
|
||||
@@ -840,6 +846,7 @@
|
||||
reportDeployDrift(new Map([...predicted].filter(([p]) => savedPaths.includes(p))))
|
||||
}
|
||||
saveErrors = errors
|
||||
if (errors.size > 0) saveErrorsOpen = true
|
||||
savingAll = false
|
||||
const savedCount = savedPaths.length + savedTriggers.length
|
||||
if (savedCount > 0 && errors.size === 0) {
|
||||
@@ -1067,6 +1074,7 @@
|
||||
const nextErrors = new Map(saveErrors)
|
||||
nextErrors.set(path, msg)
|
||||
saveErrors = nextErrors
|
||||
saveErrorsOpen = true
|
||||
}
|
||||
// Pick up the next queued rename, if any.
|
||||
const next = state.queuedPath
|
||||
@@ -1197,6 +1205,43 @@
|
||||
openTriggerDraft(source.kind, scriptPath)
|
||||
}
|
||||
}
|
||||
// Assets-only "Delete N items": one confirmation, then the trigger and the
|
||||
// script, each discarded if it is a draft and deleted if it is deployed.
|
||||
let assetDeleteTarget = $state<AssetUpstreamDelete | undefined>(undefined)
|
||||
let assetDeleteLoading = $state(false)
|
||||
async function confirmAssetUpstreamDelete() {
|
||||
const target = assetDeleteTarget
|
||||
const ws = $workspaceStore
|
||||
if (!target || !ws) return
|
||||
assetDeleteLoading = true
|
||||
const { script, trigger } = target
|
||||
try {
|
||||
if (trigger) {
|
||||
const key = triggerDraftKey(trigger.kind, trigger.path)
|
||||
if (pe.triggerDrafts.has(key)) pe.discardTriggerDraft(key)
|
||||
if (!trigger.draft) await deleteTriggerRow(trigger.kind, trigger.path, ws)
|
||||
}
|
||||
const deployed = graphRes.current?.runnables.some(
|
||||
(r) => r.path === script.path && !r.unsaved
|
||||
)
|
||||
if (pe.drafts.has(script.path)) discardDraft(script.path)
|
||||
if (deployed) {
|
||||
await ScriptService.deleteScriptByPath({ workspace: ws, path: script.path })
|
||||
pe.discardTriggerDraftsFor(script.path)
|
||||
forgetPath(script.path)
|
||||
forgetScriptCache(script.path)
|
||||
}
|
||||
const n = trigger ? 2 : 1
|
||||
sendUserToast(`Deleted ${n} item${n === 1 ? '' : 's'}`)
|
||||
assetDeleteTarget = undefined
|
||||
await graphRes.refetch()
|
||||
} catch (e: any) {
|
||||
sendUserToast(`Could not delete: ${e?.body ?? e?.message ?? String(e)}`, true)
|
||||
} finally {
|
||||
assetDeleteLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleRunnableMenuRemove(info: {
|
||||
runnable_kind: 'script' | 'flow'
|
||||
path: string
|
||||
@@ -2751,7 +2796,13 @@
|
||||
the editor context. Drafts that succeed disappear from
|
||||
the map; the ones still listed here are the unresolved
|
||||
failures. -->
|
||||
<Popover placement="bottom-end" contentClasses="p-3 max-w-[480px]" usePointerDownOutside>
|
||||
<Popover
|
||||
placement="bottom-end"
|
||||
contentClasses="p-3 max-w-[480px]"
|
||||
usePointerDownOutside
|
||||
enableFlyTransition
|
||||
bind:isOpen={saveErrorsOpen}
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<button
|
||||
type="button"
|
||||
@@ -2867,6 +2918,7 @@
|
||||
onAddScriptForAsset={mode === 'edit' ? handleAddScriptForAsset : undefined}
|
||||
onAddPipelineScript={mode === 'edit' ? handleAddPipelineScript : undefined}
|
||||
onRunnableMenuRemove={mode === 'edit' ? handleRunnableMenuRemove : undefined}
|
||||
onDeleteAssetUpstream={mode === 'edit' ? (t) => (assetDeleteTarget = t) : undefined}
|
||||
onRunProducer={mode === 'edit' ? handleRunProducer : undefined}
|
||||
onRequestEdit={isOperator ? undefined : () => setMode('edit')}
|
||||
canRunByPath={openScriptHasDataUpload}
|
||||
@@ -2996,6 +3048,38 @@
|
||||
<!-- 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={assetDeleteTarget != undefined}
|
||||
loading={assetDeleteLoading}
|
||||
title={assetDeleteTarget?.trigger ? 'Delete 2 items?' : 'Delete 1 item?'}
|
||||
confirmationText="Delete"
|
||||
onConfirmed={confirmAssetUpstreamDelete}
|
||||
onCanceled={() => {
|
||||
if (!assetDeleteLoading) assetDeleteTarget = undefined
|
||||
}}
|
||||
>
|
||||
{#if assetDeleteTarget}
|
||||
{@const t = assetDeleteTarget}
|
||||
<div class="flex flex-col gap-2 text-xs text-secondary">
|
||||
<p>These are deleted, along with every version. The tables they wrote stay.</p>
|
||||
<ul class="flex flex-col gap-1">
|
||||
<li class="flex flex-col">
|
||||
<span class="text-emphasis truncate">{t.script.path}</span>
|
||||
<span class="text-2xs text-hint">Script{t.script.unsaved ? ' · draft' : ''}</span>
|
||||
</li>
|
||||
{#if t.trigger}
|
||||
<li class="flex flex-col">
|
||||
<span class="text-emphasis truncate">{t.trigger.path}</span>
|
||||
<span class="text-2xs text-hint"
|
||||
>{t.trigger.kind} trigger{t.trigger.draft ? ' · draft' : ''}</span
|
||||
>
|
||||
</li>
|
||||
{/if}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</ConfirmationModal>
|
||||
|
||||
<ConfirmationModal
|
||||
open={confirmTriggerDeployOpen}
|
||||
title="Deploy triggers?"
|
||||
|
||||
Reference in New Issue
Block a user