diff --git a/frontend/src/lib/components/assets/AssetGraph/AddNode.svelte b/frontend/src/lib/components/assets/AssetGraph/AddNode.svelte index 8c75a168ee..dfca6235e4 100644 --- a/frontend/src/lib/components/assets/AssetGraph/AddNode.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/AddNode.svelte @@ -14,6 +14,8 @@ } from 'lucide-svelte' import type { ScriptLang } from '$lib/gen' import type { NativeTriggerKind } from './types' + import { PIPELINE_LANGUAGES } from './pipelineLanguages' + import type { PipelineOutputKind } from './pipelineTemplates' // Each left-column kind is just "pipeline script triggered by ". id === the SCRIPT_TRIGGER_KIND value, so the handler can @@ -28,7 +30,8 @@ path: string, source: | { kind: 'schedule'; cron: string } - | { kind: NativeTriggerKind; path: string | undefined } + | { kind: NativeTriggerKind; path: string | undefined }, + outputKind: PipelineOutputKind ) => void pathPrefix: string defaultPathSuffix: string @@ -37,37 +40,28 @@ } let { data }: Props = $props() - const LANGUAGES: Array<{ label: string; lang: ScriptLang }> = [ - { label: 'TypeScript (Bun)', lang: 'bun' }, - { label: 'TypeScript (Deno)', lang: 'deno' }, - { label: 'Python', lang: 'python3' }, - { label: 'PostgreSQL', lang: 'postgresql' }, - { label: 'DuckDB', lang: 'duckdb' }, - { label: 'BigQuery', lang: 'bigquery' }, - { label: 'Snowflake', lang: 'snowflake' }, - { label: 'MySQL', lang: 'mysql' }, - { label: 'MS SQL', lang: 'mssql' }, - { label: 'Bash', lang: 'bash' }, - { label: 'Go', lang: 'go' } - ] - function handlePick(pick: PipelineInsertPick) { if (!pick.language || !pick.path) return const kindId = pick.kindId as KindId + const outputKind = (pick.outputKind ?? 'none') as PipelineOutputKind if (kindId === 'schedule') { - data.onAddPipelineScript(pick.language as ScriptLang, pick.path, { - kind: 'schedule', - cron: data.defaultScheduleCron - }) + data.onAddPipelineScript( + pick.language as ScriptLang, + pick.path, + { kind: 'schedule', cron: data.defaultScheduleCron }, + outputKind + ) } else { // Native trigger reference: user is expected to fill in the // trigger path themselves in the editor (or configure it in the // trigger's own UI). We seed the annotation with an empty ref // the user replaces. - data.onAddPipelineScript(pick.language as ScriptLang, pick.path, { - kind: kindId, - path: undefined - }) + data.onAddPipelineScript( + pick.language as ScriptLang, + pick.path, + { kind: kindId, path: undefined }, + outputKind + ) } } @@ -138,7 +132,8 @@ pickLanguage: true } ]} - languages={LANGUAGES as any} + languages={PIPELINE_LANGUAGES as any} + pickOutputKind pathPrefix={data.pathPrefix} defaultPathSuffix={data.defaultPathSuffix} onPick={handlePick} diff --git a/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte b/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte index 6e4abdccf2..1aa18a4c5f 100644 --- a/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte @@ -35,7 +35,8 @@ onAddScriptForAsset?: ( asset: { kind: AssetKind; path: string }, language: import('$lib/gen').ScriptLang, - scriptPath: string + scriptPath: string, + outputKind: import('./pipelineTemplates').PipelineOutputKind ) => void // Pipeline-wide + node shown at the top of the graph. Picking any // kind from the menu invokes this one callback with the chosen @@ -48,7 +49,8 @@ | { kind: 'webhook' | 'email' | 'kafka' | 'mqtt' | 'nats' | 'postgres' | 'sqs' | 'gcp' path: string | undefined - } + }, + outputKind: import('./pipelineTemplates').PipelineOutputKind ) => void // Folder-scoped prefix shown as a read-only chip in the insert menu // path input (e.g. `f/{folder}/`). Shared across top + and per-asset +. diff --git a/frontend/src/lib/components/assets/AssetGraph/AssetNode.svelte b/frontend/src/lib/components/assets/AssetGraph/AssetNode.svelte index 359e073f9f..251e374566 100644 --- a/frontend/src/lib/components/assets/AssetGraph/AssetNode.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/AssetNode.svelte @@ -9,6 +9,8 @@ import type { ScriptLang } from '$lib/gen' import { workspaceStore } from '$lib/stores' import { sendUserToast } from '$lib/utils' + import { PIPELINE_LANGUAGES } from './pipelineLanguages' + import type { PipelineOutputKind } from './pipelineTemplates' // Shape used for both the data prop and the run callback. Drafts carry // `content` / `language` so the page-level run handler can dispatch to @@ -27,7 +29,8 @@ onAddScript?: ( asset: { kind: AssetKind; path: string }, language: ScriptLang, - scriptPath: string + scriptPath: string, + outputKind: PipelineOutputKind ) => void pathPrefix?: string defaultPathSuffix?: string @@ -84,26 +87,13 @@ } } - const LANGUAGES: Array<{ label: string; lang: ScriptLang }> = [ - { label: 'TypeScript (Bun)', lang: 'bun' }, - { label: 'TypeScript (Deno)', lang: 'deno' }, - { label: 'Python', lang: 'python3' }, - { label: 'PostgreSQL', lang: 'postgresql' }, - { label: 'DuckDB', lang: 'duckdb' }, - { label: 'BigQuery', lang: 'bigquery' }, - { label: 'Snowflake', lang: 'snowflake' }, - { label: 'MySQL', lang: 'mysql' }, - { label: 'MS SQL', lang: 'mssql' }, - { label: 'Bash', lang: 'bash' }, - { label: 'Go', lang: 'go' } - ] - function handlePick(pick: PipelineInsertPick) { if (pick.kindId === 'pipeline_script' && pick.language && pick.path) { data.onAddScript?.( { kind: data.asset_kind, path: data.path }, pick.language as ScriptLang, - pick.path + pick.path, + (pick.outputKind ?? 'none') as PipelineOutputKind ) } } @@ -182,7 +172,8 @@ pickLanguage: true } ]} - languages={LANGUAGES as any} + languages={PIPELINE_LANGUAGES as any} + pickOutputKind pathPrefix={data.pathPrefix ?? ''} defaultPathSuffix={data.defaultPathSuffix ?? ''} onPick={handlePick} diff --git a/frontend/src/lib/components/assets/AssetGraph/PipelineInsertMenu.svelte b/frontend/src/lib/components/assets/AssetGraph/PipelineInsertMenu.svelte index 327e22fadb..545c0bcb1f 100644 --- a/frontend/src/lib/components/assets/AssetGraph/PipelineInsertMenu.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/PipelineInsertMenu.svelte @@ -18,6 +18,9 @@ kindId: string language?: SupportedLanguage path?: string + // Picked output asset kind. Optional because some menu instances + // (those without `pickOutputKind`) skip that stage entirely. + outputKind?: string } @@ -27,6 +30,12 @@ import LanguageIcon from '$lib/components/common/languageIcons/LanguageIcon.svelte' import { ArrowLeft, ChevronRight } from 'lucide-svelte' import { tick } from 'svelte' + import { + PIPELINE_OUTPUT_KINDS, + compatibleOutputKinds, + type PipelineOutputKind + } from './pipelineTemplates' + import type { ScriptLang } from '$lib/gen' interface Props { kinds: PipelineInsertKind[] @@ -38,6 +47,11 @@ // Default suffix seeded into the editable input when the user // reaches the path stage (e.g. `new_pipeline_script`). defaultPathSuffix?: string + // When true, after the user picks a language we add an output-kind + // stage between language and path. The picked kind is forwarded in + // onPick(pick.outputKind). When false (or omitted), the menu jumps + // directly from language → path, matching the legacy two-stage flow. + pickOutputKind?: boolean onPick: (pick: PipelineInsertPick) => void trigger: import('svelte').Snippet placement?: 'bottom' | 'top' | 'left' | 'right' @@ -48,25 +62,35 @@ languages = [], pathPrefix = '', defaultPathSuffix = '', + pickOutputKind = false, onPick, trigger: triggerSnippet, placement = 'bottom' }: Props = $props() - // Flow stages: kind → lang → path → confirm. `stage` drives the right - // column. Only the `language` kinds reach the lang/path stages. - // Default-select the first kind, and if it needs a language jump - // straight into the lang stage so the user doesn't have to click the - // left column to see the language picker. + // Flow stages: kind → lang → (output) → path → confirm. `stage` drives + // the right column. Only the `language` kinds reach the lang/path + // stages. The `output` stage is gated behind `pickOutputKind` so menus + // that don't need it (legacy two-column callers) keep their old flow. let selectedKindId = $state(kinds[0]?.id ?? '') let selectedKind = $derived(kinds.find((k) => k.id === selectedKindId) ?? kinds[0]) - let stage = $state<'lang' | 'path' | 'description'>( + let stage = $state<'lang' | 'output' | 'path' | 'description'>( kinds[0]?.pickLanguage ? 'lang' : 'description' ) let selectedLanguage = $state(undefined) + let selectedOutputKind = $state(undefined) let pathSuffix = $state('') let pathInput: HTMLInputElement | undefined = $state(undefined) + // Output kinds that have a real template for the picked language. We + // hide non-compatible kinds entirely rather than greying them — keeps + // the picker scannable and the user never lands on a kind that would + // silently fall back to the generic body. + let compatibleKinds = $derived.by(() => { + if (!selectedLanguage) return [] + return compatibleOutputKinds(selectedLanguage as ScriptLang) + }) + // Popover closing doesn't unmount its content, so state persists across // opens. Reset everything back to initial state on close so the next // open starts fresh — otherwise reopening lands on the previous path @@ -76,6 +100,7 @@ selectedKindId = kinds[0]?.id ?? '' stage = kinds[0]?.pickLanguage ? 'lang' : 'description' selectedLanguage = undefined + selectedOutputKind = undefined pathSuffix = '' } @@ -88,6 +113,7 @@ selectedKindId = k.id stage = 'lang' selectedLanguage = undefined + selectedOutputKind = undefined pathSuffix = '' } @@ -102,24 +128,45 @@ return out } - async function handleLanguageClick(lang: SupportedLanguage) { - selectedLanguage = lang - const base = defaultPathSuffix || 'pipeline_script' - pathSuffix = `${base}_${shortSlug()}` - stage = 'path' - // Focus the suffix input so the user can just start typing a name. + async function focusPathInput() { await tick() pathInput?.focus() pathInput?.select() } + async function handleLanguageClick(lang: SupportedLanguage) { + selectedLanguage = lang + // If this menu wants an output-kind stage, route through it; the + // path suffix only gets seeded once the user has confirmed both + // language and output kind. + if (pickOutputKind) { + selectedOutputKind = undefined + stage = 'output' + return + } + const base = defaultPathSuffix || 'pipeline_script' + pathSuffix = `${base}_${shortSlug()}` + stage = 'path' + await focusPathInput() + } + + async function handleOutputKindClick(kind: PipelineOutputKind) { + selectedOutputKind = kind + const base = defaultPathSuffix || 'pipeline_script' + pathSuffix = `${base}_${shortSlug()}` + stage = 'path' + await focusPathInput() + } + function confirmPath(close: () => void) { const suffix = pathSuffix.trim() if (!suffix || !selectedLanguage) return + if (pickOutputKind && !selectedOutputKind) return onPick({ kindId: selectedKindId, language: selectedLanguage, - path: pathPrefix + suffix + path: pathPrefix + suffix, + outputKind: pickOutputKind ? selectedOutputKind : undefined }) close() } @@ -130,7 +177,7 @@ confirmPath(close) } else if (e.key === 'Escape') { e.preventDefault() - stage = 'lang' + stage = pickOutputKind ? 'output' : 'lang' } } @@ -157,10 +204,19 @@ {/snippet} {#snippet content({ close })} {@const singleKind = kinds.length === 1} + {@const widthClass = pickOutputKind + ? singleKind + ? 'w-[520px]' + : 'w-[720px]' + : singleKind + ? 'w-[360px]' + : 'w-[560px]'}
@@ -215,9 +271,9 @@ {/each}
- {:else if stage === 'path' && selectedLanguage} -
-
+ {:else if stage === 'output' && selectedLanguage} +
+
+
Output asset
+ {#each PIPELINE_OUTPUT_KINDS.filter((k) => compatibleKinds.includes(k.id)) as k} + + {/each} + {#if compatibleKinds.length === 0} + No output presets for this language. + {/if} +
+ {:else if stage === 'path' && selectedLanguage} + {@const outputMeta = selectedOutputKind + ? PIPELINE_OUTPUT_KINDS.find((k) => k.id === selectedOutputKind) + : undefined} +
+
+
Path