mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-12 16:02:56 +00:00
feat: assets-only nodes name their script in a header tab instead of a side 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
1b159d91f5
commit
cf8c0e4257
@@ -22,7 +22,11 @@
|
||||
import AssetGraphEdge from './AssetGraphEdge.svelte'
|
||||
import PanToNode from './PanToNode.svelte'
|
||||
import InitialFitView from './InitialFitView.svelte'
|
||||
import { layoutAssetGraph, PIPELINE_NODE_EXTRA_ROW } from './assetGraphLayout'
|
||||
import {
|
||||
layoutAssetGraph,
|
||||
PIPELINE_NODE_EXTRA_ROW,
|
||||
PIPELINE_NODE_HEADER
|
||||
} from './assetGraphLayout'
|
||||
import {
|
||||
assetsOnlyView,
|
||||
upstreamDeletion,
|
||||
@@ -1430,12 +1434,17 @@
|
||||
: `${upstream.trigger.label}${upstream.trigger.draft ? ' · draft' : ''}`,
|
||||
runState: 'runState' in upstream && !!upstream.runState,
|
||||
chipIcon: foldedErrors > 0,
|
||||
scriptChips:
|
||||
header:
|
||||
'runnableId' in upstream
|
||||
? 1
|
||||
: 'scripts' in upstream
|
||||
? (upstream.scripts?.length ?? 0)
|
||||
: 0
|
||||
? { label: upstream.summary || upstream.path, icons: 1 }
|
||||
: 'scripts' in upstream && upstream.scripts?.length
|
||||
? upstream.scripts.length === 1
|
||||
? {
|
||||
label: upstream.scripts[0].summary || upstream.scripts[0].path,
|
||||
icons: 1
|
||||
}
|
||||
: { label: `${upstream.scripts.length} scripts`, icons: 2 }
|
||||
: undefined
|
||||
})
|
||||
: undefined
|
||||
return {
|
||||
@@ -1583,7 +1592,11 @@
|
||||
)
|
||||
})
|
||||
let layoutPositions = $derived(
|
||||
layoutAssetGraph(layoutInput, ADD_NODE_ID, assetsOnly ? PIPELINE_NODE_EXTRA_ROW : 0)
|
||||
layoutAssetGraph(
|
||||
layoutInput,
|
||||
ADD_NODE_ID,
|
||||
assetsOnly ? PIPELINE_NODE_EXTRA_ROW + PIPELINE_NODE_HEADER : 0
|
||||
)
|
||||
)
|
||||
|
||||
/** Assets-only asset cards size to their text; every other node is `NODE.width`. */
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
ShieldCheck,
|
||||
ShieldAlert,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
CircleSlash,
|
||||
Trash2,
|
||||
XCircle
|
||||
@@ -244,8 +245,7 @@
|
||||
const n = upstream && 'scripts' in upstream ? upstream.scripts.length : 1
|
||||
return `${n} script${n === 1 ? '' : 's'}`
|
||||
})
|
||||
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 HEADER_SELECTED = 'bg-surface-accent-selected hover:bg-surface-accent-selected text-accent'
|
||||
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)
|
||||
@@ -322,37 +322,72 @@
|
||||
)
|
||||
</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 scriptHeader()}
|
||||
<!-- The script building this asset, folded into it: a strip across the top
|
||||
naming it, which opens it. Several scripts open a menu to pick one. -->
|
||||
{@const one = producer ?? (coProducers.length === 1 ? coProducers[0] : undefined)}
|
||||
{@const headerClass = twMerge(
|
||||
'flex items-center gap-1.5 w-full min-w-0 px-2 text-3xs font-normal leading-none text-secondary hover:bg-surface-hover hover:text-primary'
|
||||
)}
|
||||
{#if one}
|
||||
{@const isSelected = producer ? !!producer.selected : coSelected}
|
||||
<button
|
||||
type="button"
|
||||
class={twMerge(headerClass, isSelected && HEADER_SELECTED)}
|
||||
title={`Open ${one.summary ? `${one.summary} (${one.path})` : one.path}${one.unsaved ? ' · draft' : ''}`}
|
||||
onpointerdown={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation()
|
||||
one.onOpen()
|
||||
}}
|
||||
>
|
||||
{#if one.language}
|
||||
<LanguageIcon lang={one.language} width={11} height={11} />
|
||||
{:else}
|
||||
<Code2 size={11} class="shrink-0" />
|
||||
{/if}
|
||||
<span class="truncate">{one.summary || one.path}</span>
|
||||
{#if one.unsaved}<span class="shrink-0 text-tertiary">draft</span>{/if}
|
||||
{#if producer?.runState}
|
||||
<RunStateChip runState={producer.runState} class="ml-auto h-4 px-1 rounded" />
|
||||
{/if}
|
||||
</button>
|
||||
{:else if coProducers.length > 1}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="w-full min-w-0 flex"
|
||||
onpointerdown={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DropdownV2
|
||||
items={coProducerItems}
|
||||
placement="bottom-start"
|
||||
bind:open={scriptMenuOpen}
|
||||
fixedHeight={false}
|
||||
usePointerDownOutside
|
||||
enableFlyTransition
|
||||
class="w-full"
|
||||
>
|
||||
{#snippet buttonReplacement()}
|
||||
<span
|
||||
class={twMerge(headerClass, 'h-full', (coSelected || scriptMenuOpen) && HEADER_SELECTED)}
|
||||
title={`Built by ${coProducers.length} scripts: pick one to open`}
|
||||
>
|
||||
{#each coProducers.slice(0, 2) as sc (sc.path)}
|
||||
{#if sc.language}
|
||||
<LanguageIcon lang={sc.language} width={11} height={11} />
|
||||
{:else}
|
||||
<Code2 size={11} class="shrink-0" />
|
||||
{/if}
|
||||
{/each}
|
||||
<span class="truncate">{coProducers.length} scripts</span>
|
||||
<ChevronDown size={11} class="shrink-0 ml-auto" />
|
||||
</span>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet upstreamRow()}
|
||||
@@ -436,6 +471,7 @@
|
||||
{selected}
|
||||
tone={data.error ? 'error' : undefined}
|
||||
subtitle={upstream ? upstreamRow : undefined}
|
||||
header={producer || coProducers.length > 0 ? scriptHeader : undefined}
|
||||
width={data.width}
|
||||
surface={upstream ? 'primary' : 'secondary'}
|
||||
>
|
||||
@@ -554,50 +590,6 @@
|
||||
<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}
|
||||
{@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()}>
|
||||
<DropdownV2
|
||||
items={coProducerItems}
|
||||
placement="bottom-end"
|
||||
bind:open={scriptMenuOpen}
|
||||
fixedHeight={false}
|
||||
usePointerDownOutside
|
||||
enableFlyTransition
|
||||
>
|
||||
{#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>
|
||||
</NodeActionsMenu>
|
||||
|
||||
@@ -6,7 +6,11 @@ and the controls hung around the card.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { NODE } from '$lib/components/graph/util'
|
||||
import { PIPELINE_NODE_EXTRA_ROW, PIPELINE_NODE_HEIGHT } from './assetGraphLayout'
|
||||
import {
|
||||
PIPELINE_NODE_EXTRA_ROW,
|
||||
PIPELINE_NODE_HEADER,
|
||||
PIPELINE_NODE_HEIGHT
|
||||
} from './assetGraphLayout'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import type { Snippet } from 'svelte'
|
||||
|
||||
@@ -34,6 +38,9 @@ and the controls hung around the card.
|
||||
width?: number
|
||||
/** A line under the title, in the text column. */
|
||||
subtitle?: Snippet
|
||||
/** A tab on the card's top edge. Only on a card without `onclick`, since it
|
||||
* may hold buttons of its own. */
|
||||
header?: Snippet
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -49,6 +56,7 @@ and the controls hung around the card.
|
||||
leading,
|
||||
trailing,
|
||||
subtitle,
|
||||
header,
|
||||
width = NODE.width
|
||||
}: Props = $props()
|
||||
|
||||
@@ -119,6 +127,19 @@ and the controls hung around the card.
|
||||
>
|
||||
{@render body()}
|
||||
</button>
|
||||
{:else if header}
|
||||
<!-- The header is a tab on the card's top edge, inset from its sides. -->
|
||||
<div class="flex flex-col" style="width: {width}px;">
|
||||
<div
|
||||
class="mx-2 flex items-stretch min-w-0 rounded-t-md overflow-hidden bg-surface-secondary border border-b-0 border-gray-200 dark:border-gray-700"
|
||||
style="height: {PIPELINE_NODE_HEADER}px;"
|
||||
>
|
||||
{@render header()}
|
||||
</div>
|
||||
<div class={cardClass} style="min-height: {minHeight}px;" title={tooltip}>
|
||||
{@render body()}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class={cardClass}
|
||||
|
||||
@@ -21,6 +21,8 @@ const COMPONENT_GAP = SIBLING_GAP * 2
|
||||
|
||||
/** Extra height of an assets-only node: the trigger chip under its title. */
|
||||
export const PIPELINE_NODE_EXTRA_ROW = 16
|
||||
/** Height of the header strip naming an assets-only node's producing script. */
|
||||
export const PIPELINE_NODE_HEADER = 22
|
||||
|
||||
interface GraphInput {
|
||||
/** `width` defaults to `NODE.width`. */
|
||||
|
||||
@@ -18,8 +18,8 @@ function textWidth(text: string, rem: number): number {
|
||||
|
||||
/**
|
||||
* Width of an assets-only asset card, from the pieces `PipelineNodeCard` and
|
||||
* `AssetNode` lay out: icon, the widest of kind / title / trigger chip, and the
|
||||
* run and script chips. The numbers mirror their Tailwind spacing; change one
|
||||
* `AssetNode` lay out: icon and the widest of kind / title / trigger chip, or the
|
||||
* header naming the producing script, if wider. The numbers mirror their Tailwind spacing; change one
|
||||
* and the other drifts. Clamped to the regular node width, past which the title
|
||||
* truncates as usual.
|
||||
*/
|
||||
@@ -30,8 +30,9 @@ export function assetsOnlyNodeWidth(n: {
|
||||
/** The chip leads with an icon (the folded-errors Fix chip). */
|
||||
chipIcon?: boolean
|
||||
runState: boolean
|
||||
/** Producing scripts: one chip for one, two icons and a "+N" for more. */
|
||||
scriptChips: number
|
||||
/** The header's text (a script's summary or path, "N scripts"), with its icon
|
||||
* count; undefined without a header. */
|
||||
header?: { label: string; icons: number }
|
||||
}): number {
|
||||
const icon = 12 + 14 + 10
|
||||
const text = Math.max(
|
||||
@@ -39,14 +40,18 @@ export function assetsOnlyNodeWidth(n: {
|
||||
textWidth(n.title, 0.75),
|
||||
textWidth(n.chip, 0.65) + 12 + 2 + (n.chipIcon ? 14 : 0)
|
||||
)
|
||||
// The run chip is sized for a two-digit count, so a new run never re-lays the graph.
|
||||
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))
|
||||
const body = Math.ceil(icon + text + 4 + 8 + 2 + 8)
|
||||
// Header: px-2, icons with gap-1.5, the label, then the run chip (sized for a
|
||||
// two-digit count, so a new run never re-lays the graph) or the menu chevron.
|
||||
const header = n.header
|
||||
? Math.ceil(
|
||||
8 +
|
||||
n.header.icons * (11 + 6) +
|
||||
textWidth(n.header.label, 0.65) +
|
||||
6 +
|
||||
(n.runState ? 11 + 4 + textWidth('×99', 0.65) + 8 : 11) +
|
||||
8
|
||||
)
|
||||
: 0
|
||||
return Math.min(NODE.width, Math.max(MIN_WIDTH, body, header))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user