mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 00:03:00 +00:00
feat: edit an asset's producing script from its pane and multi-script chip
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
a3d46f7364
commit
be5e651f20
@@ -1021,7 +1021,21 @@
|
||||
? undefined
|
||||
: { none: true as const }
|
||||
: u.multiple || !r
|
||||
? { multiple: true as const }
|
||||
? {
|
||||
multiple: true as const,
|
||||
scripts: (u.multiple ? u.runnableIds : [])
|
||||
.map((id) => runnables.get(id)?.data)
|
||||
.filter((d) => d != undefined)
|
||||
.map((d) => ({
|
||||
runnableId: `${d.runnable_kind}:${d.path}`,
|
||||
path: d.path as string,
|
||||
summary: d.summary as string | undefined,
|
||||
language: d.language,
|
||||
unsaved: d.unsaved as boolean | undefined,
|
||||
onOpen: () =>
|
||||
onselect?.({ kind: 'runnable', runnable_kind: d.runnable_kind, path: d.path })
|
||||
}))
|
||||
}
|
||||
: {
|
||||
runnableId: u.runnableId,
|
||||
path: r.path,
|
||||
@@ -1059,7 +1073,8 @@
|
||||
? 'Multiple upstream nodes'
|
||||
: `${upstream.trigger.label}${upstream.trigger.draft ? ' · draft' : ''}`,
|
||||
runState: 'runState' in upstream && !!upstream.runState,
|
||||
scriptChip: 'runnableId' in upstream
|
||||
scriptChips:
|
||||
'runnableId' in upstream ? 1 : 'scripts' in upstream ? (upstream.scripts?.length ?? 0) : 0
|
||||
})
|
||||
: undefined
|
||||
return {
|
||||
@@ -1218,7 +1233,15 @@
|
||||
// never re-runs the layout.
|
||||
const up = n.type === 'asset' ? n.data.upstream : undefined
|
||||
const data =
|
||||
up?.runnableId !== undefined
|
||||
up?.scripts
|
||||
? {
|
||||
...n.data,
|
||||
upstream: {
|
||||
...up,
|
||||
selected: up.scripts.some((sc: { runnableId: string }) => sc.runnableId === selectedId)
|
||||
}
|
||||
}
|
||||
: up?.runnableId !== undefined
|
||||
? {
|
||||
...n.data,
|
||||
upstream: {
|
||||
|
||||
@@ -20,8 +20,11 @@
|
||||
SquareFunction,
|
||||
Trash2,
|
||||
X,
|
||||
Pencil
|
||||
Pencil,
|
||||
ChevronDown
|
||||
} from 'lucide-svelte'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import { upstreamScriptItems, type UpstreamScript } from './upstreamScriptItems'
|
||||
import { inferArgs } from '$lib/infer'
|
||||
import { emptySchema, sendUserToast } from '$lib/utils'
|
||||
import type { Schema } from '$lib/common'
|
||||
@@ -157,6 +160,10 @@
|
||||
path: string
|
||||
unsaved?: boolean
|
||||
}>
|
||||
/** The selected asset's producing scripts, for its "Edit script" action,
|
||||
* which calls `onEditScript` with the one picked. */
|
||||
producerScripts?: UpstreamScript[]
|
||||
onEditScript?: (path: string) => void
|
||||
// Pipeline-wide column-lineage graph (built by the parent page from the
|
||||
// resolved graph). Drives the transitive column-lineage trace shown for a
|
||||
// selected materialized asset.
|
||||
@@ -305,6 +312,8 @@
|
||||
onScriptRenamed,
|
||||
onScriptRemoved,
|
||||
selectionProducers = [],
|
||||
producerScripts = [],
|
||||
onEditScript,
|
||||
selectionColumnGraph,
|
||||
selectionColumnLoading = false,
|
||||
selectionColumnTruncated = false,
|
||||
@@ -1018,6 +1027,36 @@
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
{#if selection?.kind === 'asset' && onEditScript && producerScripts.length === 1}
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: Pencil }}
|
||||
onclick={() => onEditScript(producerScripts[0].path)}
|
||||
title={`Open ${producerScripts[0].path}, which builds this asset`}
|
||||
>
|
||||
Edit script
|
||||
</Button>
|
||||
{:else if selection?.kind === 'asset' && onEditScript && producerScripts.length > 1}
|
||||
<DropdownV2
|
||||
items={upstreamScriptItems(producerScripts, onEditScript)}
|
||||
placement="bottom-end"
|
||||
enableFlyTransition
|
||||
>
|
||||
{#snippet buttonReplacement()}
|
||||
<Button
|
||||
nonCaptureEvent
|
||||
variant="default"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: Pencil }}
|
||||
endIcon={{ icon: ChevronDown }}
|
||||
title={`${producerScripts.length} scripts build this asset`}
|
||||
>
|
||||
Edit script
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
{/if}
|
||||
{#if readOnly && onRequestEdit}
|
||||
<Button
|
||||
variant="accent"
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
import DbtIcon from '$lib/components/icons/DbtIcon.svelte'
|
||||
import LanguageIcon from '$lib/components/common/languageIcons/LanguageIcon.svelte'
|
||||
import RunStateChip from './RunStateChip.svelte'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import { upstreamScriptItems } from './upstreamScriptItems'
|
||||
import type { RunnableRunState } from './activeRunnables.svelte'
|
||||
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||||
|
||||
@@ -40,7 +42,19 @@
|
||||
// non-deployed scripts).
|
||||
export type AssetUpstreamChips =
|
||||
| { none: true }
|
||||
| { multiple: true }
|
||||
| {
|
||||
multiple: true
|
||||
/** The producing scripts; one when only its triggers are ambiguous. */
|
||||
scripts: Array<{
|
||||
runnableId: string
|
||||
path: string
|
||||
summary?: string
|
||||
language?: ScriptLang
|
||||
unsaved?: boolean
|
||||
onOpen: () => void
|
||||
}>
|
||||
selected?: boolean
|
||||
}
|
||||
| {
|
||||
runnableId: string
|
||||
path: string
|
||||
@@ -208,9 +222,19 @@
|
||||
: []
|
||||
)
|
||||
let upstream = $derived(data.upstream)
|
||||
const CHIP_CLASS =
|
||||
'shrink-0 h-5 min-w-5 rounded-md border bg-surface text-secondary hover:bg-surface-hover border-gray-300 dark:border-gray-600'
|
||||
const CHIP_SELECTED =
|
||||
'bg-surface-accent-selected border-border-selected hover:bg-surface-accent-selected text-accent'
|
||||
let producer = $derived(upstream && 'path' in upstream ? upstream : undefined)
|
||||
let coProducers = $derived(upstream && 'scripts' in upstream ? upstream.scripts : [])
|
||||
let coSelected = $derived(!!(upstream && 'scripts' in upstream && upstream.selected))
|
||||
let coProducerItems = $derived(
|
||||
upstreamScriptItems(coProducers, (path) =>
|
||||
coProducers.find((s) => s.path === path)?.onOpen()
|
||||
)
|
||||
)
|
||||
let scriptMenuOpen = $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
|
||||
@@ -278,11 +302,44 @@
|
||||
)
|
||||
</script>
|
||||
|
||||
{#snippet scriptChip(sc: {
|
||||
path: string
|
||||
summary?: string
|
||||
language?: ScriptLang
|
||||
unsaved?: boolean
|
||||
selected?: boolean
|
||||
onOpen: () => void
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
class={twMerge(
|
||||
CHIP_CLASS,
|
||||
'grid place-items-center px-1',
|
||||
sc.unsaved && 'border-dashed border-gray-400 dark:border-gray-500',
|
||||
sc.selected && CHIP_SELECTED
|
||||
)}
|
||||
title={`Open ${sc.summary ? `${sc.summary} (${sc.path})` : sc.path}${sc.unsaved ? ' · draft' : ''}`}
|
||||
aria-label="Open the script that produces this asset"
|
||||
onpointerdown={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation()
|
||||
sc.onOpen()
|
||||
}}
|
||||
>
|
||||
{#if sc.language}
|
||||
<LanguageIcon lang={sc.language} width={12} height={12} />
|
||||
{:else}
|
||||
<Code2 size={12} />
|
||||
{/if}
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
{#snippet upstreamRow()}
|
||||
{#if upstream}
|
||||
{@const trigger = producer?.trigger}
|
||||
{@const chipClass = twMerge(
|
||||
'truncate rounded-md border px-1.5 py-0.5 text-3xs leading-none font-normal',
|
||||
'truncate rounded-md border px-1.5 py-0.5 text-3xs leading-none font-normal bg-surface',
|
||||
trigger?.missing
|
||||
? 'border-red-300 dark:border-red-600 text-red-700 dark:text-red-300'
|
||||
: 'border-gray-300 dark:border-gray-600 text-secondary',
|
||||
@@ -462,28 +519,47 @@
|
||||
{#if producer.runState}
|
||||
<RunStateChip runState={producer.runState} class="mr-1 h-5 px-1.5 rounded-md" />
|
||||
{/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"
|
||||
{@render scriptChip(producer)}
|
||||
{:else if coProducers.length === 1}
|
||||
{@render scriptChip({ ...coProducers[0], selected: coSelected })}
|
||||
{:else if coProducers.length > 1}
|
||||
<!-- Several scripts build this table: the first two languages and a
|
||||
count, opening a menu to pick which one to open. -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
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>
|
||||
<DropdownV2
|
||||
items={coProducerItems}
|
||||
placement="bottom-end"
|
||||
bind:open={scriptMenuOpen}
|
||||
fixedHeight={false}
|
||||
usePointerDownOutside
|
||||
>
|
||||
{#snippet buttonReplacement()}
|
||||
<span
|
||||
class={twMerge(
|
||||
CHIP_CLASS,
|
||||
'flex items-center gap-0.5 px-1',
|
||||
(coSelected || scriptMenuOpen) && CHIP_SELECTED
|
||||
)}
|
||||
title={`Built by ${coProducers.length} scripts: pick one to open`}
|
||||
>
|
||||
{#each coProducers.slice(0, 2) as s (s.path)}
|
||||
{#if s.language}
|
||||
<LanguageIcon lang={s.language} width={12} height={12} />
|
||||
{:else}
|
||||
<Code2 size={12} />
|
||||
{/if}
|
||||
{/each}
|
||||
{#if coProducers.length > 2}
|
||||
<span class="text-3xs leading-none">+{coProducers.length - 2}</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</PipelineNodeCard>
|
||||
@@ -566,7 +642,7 @@
|
||||
>
|
||||
<Plus size={16} strokeWidth={2.5} class="shrink-0" />
|
||||
<span
|
||||
class="max-w-0 opacity-0 whitespace-nowrap text-2xs font-normal transition-[max-width,opacity,margin] duration-200 ease-out group-hover/add:max-w-32 group-hover/add:opacity-100 group-hover/add:ml-1 group-hover/add:mr-2 group-data-[open=true]/add:max-w-32 group-data-[open=true]/add:opacity-100 group-data-[open=true]/add:ml-1 group-data-[open=true]/add:mr-2"
|
||||
class="max-w-0 opacity-0 whitespace-nowrap text-3xs font-normal transition-[max-width,opacity,margin] duration-200 ease-out group-hover/add:max-w-32 group-hover/add:opacity-100 group-hover/add:ml-1 group-hover/add:mr-2 group-data-[open=true]/add:max-w-32 group-data-[open=true]/add:opacity-100 group-data-[open=true]/add:ml-1 group-data-[open=true]/add:mr-2"
|
||||
>
|
||||
Add downstream step
|
||||
</span>
|
||||
|
||||
@@ -279,6 +279,21 @@
|
||||
}
|
||||
})
|
||||
|
||||
// The selected asset's producing scripts, named and iconed from the graph, for
|
||||
// the pane's "Edit script".
|
||||
let producerScripts = $derived(
|
||||
selectionProducers
|
||||
.filter((p) => p.kind === 'script')
|
||||
.map((p) => {
|
||||
const r = displayGraph.runnables.find((x) => x.usage_kind === 'script' && x.path === p.path)
|
||||
return { path: p.path, summary: r?.summary, language: r?.language }
|
||||
})
|
||||
)
|
||||
function editProducerScript(path: string) {
|
||||
if (mode !== 'edit') onRequestEdit?.()
|
||||
onSelect({ kind: 'runnable', runnable_kind: 'script', path })
|
||||
}
|
||||
|
||||
// ===================== Draft autosave (persistDrafts only) =====================
|
||||
// All of this folder's in-flight drafts live in ONE per-user DB draft (typ
|
||||
// `data_pipeline`) keyed at the folder, syncing across devices + surfacing in
|
||||
@@ -576,6 +591,8 @@
|
||||
{localScriptsVersion}
|
||||
selection={activeDraft ? undefined : editor.selection}
|
||||
selectionProducers={activeDraft ? [] : selectionProducers}
|
||||
{producerScripts}
|
||||
onEditScript={editProducerScript}
|
||||
{selectionColumnGraph}
|
||||
{selectionColumnLoading}
|
||||
{selectionColumnTruncated}
|
||||
|
||||
@@ -28,7 +28,8 @@ export function assetsOnlyNodeWidth(n: {
|
||||
title: string
|
||||
chip: string
|
||||
runState: boolean
|
||||
scriptChip: boolean
|
||||
/** Producing scripts: one chip for one, two icons and a "+N" for more. */
|
||||
scriptChips: number
|
||||
}): number {
|
||||
const icon = 12 + 14 + 10
|
||||
const text = Math.max(
|
||||
@@ -37,8 +38,13 @@ export function assetsOnlyNodeWidth(n: {
|
||||
textWidth(n.chip, 0.65) + 12 + 2
|
||||
)
|
||||
// The run chip is sized for a two-digit count, so a new run never re-lays the graph.
|
||||
const trailing =
|
||||
(n.runState ? 12 + 10 + 2 + textWidth('×99', 0.65) + 4 : 0) + (n.scriptChip ? 20 : 0) + 8
|
||||
const scripts =
|
||||
n.scriptChips === 0
|
||||
? 0
|
||||
: n.scriptChips === 1
|
||||
? 20
|
||||
: 8 + 2 + 12 * 2 + 2 + (n.scriptChips > 2 ? textWidth(`+${n.scriptChips - 2}`, 0.65) + 2 : 0)
|
||||
const trailing = (n.runState ? 12 + 10 + 2 + textWidth('×99', 0.65) + 4 : 0) + scripts + 8
|
||||
const width = Math.ceil(icon + text + 4 + trailing + 2 + 8)
|
||||
return Math.min(NODE.width, Math.max(MIN_WIDTH, width))
|
||||
}
|
||||
|
||||
@@ -51,7 +51,10 @@ describe('assetsOnlyView', () => {
|
||||
runnableId: 'script:f/p/clean',
|
||||
trigger: { kind: 'schedule', data: { schedule: '0 0 4 * * *' } }
|
||||
})
|
||||
expect(v.upstream.get('asset:ducklake:report')).toEqual({ multiple: true })
|
||||
expect(v.upstream.get('asset:ducklake:report')).toEqual({
|
||||
multiple: true,
|
||||
runnableIds: ['script:f/p/report', 'script:f/p/other']
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -81,6 +84,15 @@ describe('upstreamDeletion', () => {
|
||||
expect(upstreamDeletion(u, script, new Map([['asset:x', u]]))?.trigger).toBeUndefined()
|
||||
})
|
||||
|
||||
it('deletes nothing when the script also co-produces another asset', () => {
|
||||
const u = own(schedule(['f/p/a']))
|
||||
const all = new Map<string, AssetUpstream>([
|
||||
['asset:x', u],
|
||||
['asset:y', { multiple: true, runnableIds: ['script:f/p/a', 'script:f/p/b'] }]
|
||||
])
|
||||
expect(upstreamDeletion(u, script, all)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('deletes nothing when the script builds another asset too', () => {
|
||||
const u = own(schedule(['f/p/a']))
|
||||
const all = new Map([
|
||||
|
||||
@@ -18,7 +18,8 @@ export type AssetUpstreamDelete = {
|
||||
|
||||
/** What an asset shows of the script and trigger that produce it, in the assets-only view. */
|
||||
export type AssetUpstream =
|
||||
| { multiple: true }
|
||||
/** Several producing scripts, or one with several triggers. */
|
||||
| { multiple: true; runnableIds: string[] }
|
||||
| {
|
||||
multiple: false
|
||||
/** Canvas id of the producing runnable (`script:<path>`). */
|
||||
@@ -75,7 +76,7 @@ export function assetsOnlyView<N extends ViewNode, E extends ViewEdge>(
|
||||
for (const [assetId, writes] of producersOf) {
|
||||
const runnableIds = [...new Set(writes.map((w) => w.source))]
|
||||
if (runnableIds.length !== 1) {
|
||||
upstream.set(assetId, { multiple: true })
|
||||
upstream.set(assetId, { multiple: true, runnableIds })
|
||||
continue
|
||||
}
|
||||
const runnableId = runnableIds[0]
|
||||
@@ -89,7 +90,7 @@ export function assetsOnlyView<N extends ViewNode, E extends ViewEdge>(
|
||||
).values()
|
||||
]
|
||||
if (triggers.length > 1) {
|
||||
upstream.set(assetId, { multiple: true })
|
||||
upstream.set(assetId, { multiple: true, runnableIds })
|
||||
continue
|
||||
}
|
||||
const t = triggers[0]
|
||||
@@ -119,7 +120,9 @@ export function upstreamDeletion(
|
||||
): AssetUpstreamDelete | undefined {
|
||||
if (r.runnable_kind !== 'script') return undefined
|
||||
const producesOthers =
|
||||
[...all.values()].filter((o) => !o.multiple && o.runnableId === u.runnableId).length > 1
|
||||
[...all.values()].filter((o) =>
|
||||
o.multiple ? o.runnableIds.includes(u.runnableId) : o.runnableId === u.runnableId
|
||||
).length > 1
|
||||
if (producesOthers) return undefined
|
||||
const t = u.trigger
|
||||
const kind = t?.kind as NativeTriggerKind | undefined
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Item } from '$lib/utils'
|
||||
import type { ScriptLang } from '$lib/gen'
|
||||
import LanguageIcon from '$lib/components/common/languageIcons/LanguageIcon.svelte'
|
||||
import { Code2 } from 'lucide-svelte'
|
||||
|
||||
export type UpstreamScript = { path: string; summary?: string; language?: ScriptLang }
|
||||
|
||||
/** A menu of the scripts that produce an asset, to pick the one to open. */
|
||||
export function upstreamScriptItems(
|
||||
scripts: UpstreamScript[],
|
||||
onPick: (path: string) => void
|
||||
): Item[] {
|
||||
return scripts.map((s) => ({
|
||||
displayName: s.summary || s.path,
|
||||
description: s.summary ? s.path : undefined,
|
||||
...(s.language
|
||||
? { icon: LanguageIcon, iconProps: { lang: s.language, width: 14, height: 14 } }
|
||||
: { icon: Code2 }),
|
||||
action: () => onPick(s.path)
|
||||
}))
|
||||
}
|
||||
Reference in New Issue
Block a user