mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-22 00:03:35 +00:00
feat: assets-only trigger chip under the title, editable and selectable
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
dcd2fc4912
commit
e78d1d6fb5
@@ -904,23 +904,57 @@
|
||||
|
||||
let model = $derived(build(graph))
|
||||
|
||||
/** The label of the chip naming what runs an asset's producer. */
|
||||
function triggerChip(t: (AssetUpstream & { multiple: false })['trigger']): {
|
||||
// The trigger chip last clicked in the assets-only view, by trigger node id: it
|
||||
// opens a drawer rather than a selection, so it is highlighted until the next pick.
|
||||
let activeTriggerId = $state<string | undefined>(undefined)
|
||||
$effect(() => {
|
||||
void selection
|
||||
activeTriggerId = undefined
|
||||
})
|
||||
|
||||
/** The chip naming what runs an asset's producer, and the editor it opens: the
|
||||
* same routes the trigger's own node takes. */
|
||||
function triggerChip(
|
||||
t: (AssetUpstream & { multiple: false })['trigger'],
|
||||
scriptPath: string
|
||||
): {
|
||||
label: string
|
||||
nodeId?: string
|
||||
missing?: boolean
|
||||
draft?: boolean
|
||||
onOpen?: () => void
|
||||
} {
|
||||
if (!t) return { label: 'Manual' }
|
||||
if (t.kind === 'asset') return { label: 'On asset change' }
|
||||
const style = TRIGGER_NODE_STYLE[t.kind as TriggerNodeKind]
|
||||
const kind = t.kind as NativeTriggerKind
|
||||
const style = TRIGGER_NODE_STYLE[kind]
|
||||
const label =
|
||||
t.kind === 'schedule' && t.data?.schedule
|
||||
? (describeSchedule(t.data.schedule, t.data.timezone) ?? `Schedule (${t.data.schedule})`)
|
||||
: (style?.label ?? t.kind)
|
||||
const rowless = kind === 'webhook' || kind === 'data_upload'
|
||||
const missing = !!t.data?.missing && !rowless
|
||||
const open =
|
||||
kind === 'webhook'
|
||||
? onOpenWebhook && (() => onOpenWebhook(scriptPath))
|
||||
: kind === 'data_upload'
|
||||
? onOpenDataUpload && (() => onOpenDataUpload(scriptPath))
|
||||
: missing
|
||||
? onCreateMissingTrigger && (() => onCreateMissingTrigger(kind, scriptPath))
|
||||
: t.data?.ref && onEditTrigger
|
||||
? () => onEditTrigger(kind, t.data.ref, scriptPath)
|
||||
: undefined
|
||||
return {
|
||||
label,
|
||||
missing: t.data?.missing && t.kind !== 'webhook' && t.kind !== 'data_upload',
|
||||
draft: t.data?.draft
|
||||
nodeId: t.nodeId,
|
||||
missing,
|
||||
draft: t.data?.draft,
|
||||
onOpen: open
|
||||
? () => {
|
||||
activeTriggerId = t.nodeId
|
||||
open()
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -948,12 +982,13 @@
|
||||
: u.multiple || !r
|
||||
? { multiple: true }
|
||||
: {
|
||||
runnableId: u.runnableId,
|
||||
path: r.path,
|
||||
summary: r.summary,
|
||||
language: r.language,
|
||||
unsaved: r.unsaved,
|
||||
runState: r.runState,
|
||||
trigger: triggerChip(u.trigger),
|
||||
trigger: triggerChip(u.trigger, r.path),
|
||||
onOpen: () =>
|
||||
onselect?.({
|
||||
kind: 'runnable',
|
||||
@@ -1098,11 +1133,28 @@
|
||||
else if (!boundPick.eligible.has(n.id)) boundClass = 'wm-bound-dim'
|
||||
}
|
||||
const dbtClass = dbtEmphasisIds.has(n.id) ? 'wm-dbt-linked' : undefined
|
||||
// The chips' selected look rides on the positioned nodes, so a selection
|
||||
// never re-runs the layout.
|
||||
const up = n.type === 'asset' ? n.data.upstream : undefined
|
||||
const data =
|
||||
up?.runnableId !== undefined
|
||||
? {
|
||||
...n.data,
|
||||
upstream: {
|
||||
...up,
|
||||
selected: up.runnableId === selectedId,
|
||||
trigger: {
|
||||
...up.trigger,
|
||||
selected: !!up.trigger.nodeId && up.trigger.nodeId === activeTriggerId
|
||||
}
|
||||
}
|
||||
}
|
||||
: n.data
|
||||
return {
|
||||
id: n.id,
|
||||
type: n.type,
|
||||
position: { x: p.x + xCenter, y: p.y + 40 },
|
||||
data: n.data,
|
||||
data,
|
||||
class: boundClass ?? dbtClass ?? runClass ?? assetClass,
|
||||
selected: n.id === selectedId,
|
||||
// All nodes non-draggable: the layout is sugiyama-computed,
|
||||
@@ -1423,6 +1475,7 @@
|
||||
) {
|
||||
return
|
||||
}
|
||||
activeTriggerId = undefined
|
||||
onselect(undefined)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -40,13 +40,22 @@
|
||||
| { none: true }
|
||||
| { multiple: true }
|
||||
| {
|
||||
runnableId: string
|
||||
path: string
|
||||
summary?: string
|
||||
language?: ScriptLang
|
||||
unsaved?: boolean
|
||||
runState?: RunnableRunState
|
||||
trigger: { label: string; missing?: boolean; draft?: boolean }
|
||||
trigger: {
|
||||
label: string
|
||||
missing?: boolean
|
||||
draft?: boolean
|
||||
/** Opens the trigger's editor; unset for what has none (manual, asset change). */
|
||||
onOpen?: () => void
|
||||
selected?: boolean
|
||||
}
|
||||
onOpen: () => void
|
||||
selected?: boolean
|
||||
}
|
||||
|
||||
export type AssetProducer = {
|
||||
@@ -179,6 +188,8 @@
|
||||
|
||||
let showAdd = $derived(data.onAddScript != undefined)
|
||||
let upstream = $derived(data.upstream)
|
||||
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)
|
||||
|
||||
// dbt badge. `materialized` is dbt's own word rather than the Windmill
|
||||
@@ -247,30 +258,47 @@
|
||||
)
|
||||
</script>
|
||||
|
||||
{#snippet upstreamRow()}
|
||||
{#if upstream}
|
||||
<span
|
||||
class={twMerge(
|
||||
'truncate rounded-md border px-1.5 py-0.5 text-3xs leading-none',
|
||||
producer?.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',
|
||||
producer?.trigger.draft && 'border-dashed'
|
||||
)}
|
||||
title={producer?.trigger.missing
|
||||
? `No ${producer.trigger.label} trigger targets ${producer.path} yet`
|
||||
: undefined}
|
||||
{#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',
|
||||
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',
|
||||
trigger?.draft && 'border-dashed',
|
||||
trigger?.onOpen && 'hover:bg-surface-hover cursor-pointer',
|
||||
trigger?.selected && CHIP_SELECTED
|
||||
)}
|
||||
{#if trigger?.onOpen}
|
||||
<button
|
||||
type="button"
|
||||
class={chipClass}
|
||||
title={trigger.missing
|
||||
? `No ${trigger.label} trigger targets ${producer?.path} yet: click to create one`
|
||||
: `Edit the ${trigger.label} trigger`}
|
||||
onpointerdown={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation()
|
||||
trigger.onOpen?.()
|
||||
}}
|
||||
>
|
||||
{trigger.label}{trigger.draft ? ' · draft' : ''}
|
||||
</button>
|
||||
{:else}
|
||||
<span class={chipClass}>
|
||||
{#if 'none' in upstream}
|
||||
External source
|
||||
{:else if producer}
|
||||
{producer.trigger.label}{producer.trigger.draft ? ' · draft' : ''}
|
||||
{:else if trigger}
|
||||
{trigger.label}
|
||||
{:else}
|
||||
Multiple upstream nodes
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<!-- onmouseenter/leave on the wrapper (not the inner card) so the run
|
||||
button — which floats outside the card — keeps the hover state alive
|
||||
@@ -294,7 +322,8 @@
|
||||
tooltip={data.error ? `${data.path}: ${data.error}` : data.path}
|
||||
{selected}
|
||||
tone={data.error ? 'error' : undefined}
|
||||
footer={upstream ? upstreamRow : undefined}
|
||||
subtitle={upstream ? upstreamRow : undefined}
|
||||
surface={upstream ? 'primary' : 'secondary'}
|
||||
>
|
||||
{#snippet icon()}
|
||||
<!-- Data identity carries the accent (luminance blue), pairing with
|
||||
@@ -413,11 +442,15 @@
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 h-5 min-w-5 px-1 grid place-items-center rounded-md border bg-surface text-secondary hover:bg-surface-hover {producer.unsaved
|
||||
? 'border-dashed border-gray-400 dark:border-gray-500'
|
||||
: 'border-gray-300 dark:border-gray-600'}"
|
||||
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()
|
||||
|
||||
@@ -29,8 +29,8 @@ and the controls hung around the card.
|
||||
/** Between the icon and the text: a live status the eye should find first. */
|
||||
leading?: Snippet
|
||||
trailing?: Snippet
|
||||
/** A second row under the title, as wide as the card. */
|
||||
footer?: Snippet
|
||||
/** A line under the title, in the text column. */
|
||||
subtitle?: Snippet
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -45,12 +45,12 @@ and the controls hung around the card.
|
||||
onclick,
|
||||
leading,
|
||||
trailing,
|
||||
footer
|
||||
subtitle
|
||||
}: Props = $props()
|
||||
|
||||
const cardClass = $derived(
|
||||
twMerge(
|
||||
'flex flex-col justify-center w-full text-left font-normal rounded-md drop-shadow-sm overflow-hidden border transition-colors',
|
||||
'flex items-center w-full text-left font-normal rounded-md drop-shadow-sm overflow-hidden border transition-colors',
|
||||
surface === 'primary' ? 'bg-surface' : 'bg-surface-secondary',
|
||||
'border-gray-400 dark:border-gray-600 hover:border-gray-500 dark:hover:border-gray-500',
|
||||
draft && 'border-dashed border-gray-400 dark:border-gray-500',
|
||||
@@ -69,7 +69,7 @@ and the controls hung around the card.
|
||||
'bg-amber-50 dark:bg-amber-900/30 border-amber-400 dark:border-amber-600 animate-pulse'
|
||||
)
|
||||
)
|
||||
const minHeight = $derived(PIPELINE_NODE_HEIGHT + (footer ? PIPELINE_NODE_EXTRA_ROW : 0))
|
||||
const minHeight = $derived(PIPELINE_NODE_HEIGHT + (subtitle ? PIPELINE_NODE_EXTRA_ROW : 0))
|
||||
const toneText = $derived(
|
||||
tone === 'danger'
|
||||
? 'text-red-700 dark:text-red-400'
|
||||
@@ -82,13 +82,6 @@ and the controls hung around the card.
|
||||
</script>
|
||||
|
||||
{#snippet body()}
|
||||
<span class="flex items-center w-full">{@render row()}</span>
|
||||
{#if footer}
|
||||
<span class="flex items-center gap-1 w-full min-w-0 pl-3 pr-2 pb-1.5">{@render footer()}</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet row()}
|
||||
<span class="shrink-0 ml-3 mr-2.5 flex items-center">{@render icon()}</span>
|
||||
{@render leading?.()}
|
||||
<span class="flex flex-col min-w-0 flex-1 pr-1 py-1 leading-tight">
|
||||
@@ -96,6 +89,9 @@ and the controls hung around the card.
|
||||
<span class={twMerge('text-xs truncate text-emphasis', selected && 'text-accent', toneText)}
|
||||
>{title}</span
|
||||
>
|
||||
{#if subtitle}
|
||||
<span class="flex items-center min-w-0 mt-1">{@render subtitle()}</span>
|
||||
{/if}
|
||||
</span>
|
||||
{#if trailing}
|
||||
<span class="flex items-center shrink-0 pr-2">{@render trailing()}</span>
|
||||
|
||||
@@ -18,8 +18,8 @@ const SIBLING_GAP = NODE.gap.horizontal
|
||||
// into each other's columns — but only ~2×, so they don't drift far apart.
|
||||
const COMPONENT_GAP = SIBLING_GAP * 2
|
||||
|
||||
/** Extra height of an assets-only node: the row of upstream chips under the title. */
|
||||
export const PIPELINE_NODE_EXTRA_ROW = 24
|
||||
/** Extra height of an assets-only node: the trigger chip under its title. */
|
||||
export const PIPELINE_NODE_EXTRA_ROW = 16
|
||||
|
||||
interface GraphInput {
|
||||
nodes: Array<{ id: string; data: AssetGraphNodeData }>
|
||||
|
||||
Reference in New Issue
Block a user