diff --git a/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte b/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte index 856d796379..2bc8121180 100644 --- a/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte @@ -1462,9 +1462,9 @@ foldedIds, ...(toDelete ? { - deleteCount: toDelete.trigger ? 2 : 1, + upstreamDelete: toDelete, deleteVerb: assetDeleteVerb, - onDeleteUpstream: () => onDeleteAssetUpstream?.(toDelete) + onDeleteUpstream: (t: AssetUpstreamDelete) => onDeleteAssetUpstream?.(t) } : onDeleteAssetUpstream && u ? { @@ -1705,8 +1705,13 @@ .map((id) => toFix(nodeFixes?.get(id))) .filter((f) => f != undefined) const scriptsToDrag = onAddAssetTrigger ? dragScripts(n) : [] + // Assets-only: a folded script with an error reddens the header naming it. + const scriptError = ((n.data.foldedIds ?? []) as string[]).some( + (id) => id.startsWith('script:') && nodeFixes?.has(id) + ) const data = { ...withUpstream, + ...(scriptError ? { scriptError } : {}), ...(fix ? { fix } : {}), ...(foldedFixes.length ? { foldedFixes } : {}), ...(scriptsToDrag.length diff --git a/frontend/src/lib/components/assets/AssetGraph/AssetNode.svelte b/frontend/src/lib/components/assets/AssetGraph/AssetNode.svelte index c031e5c554..d023691f67 100644 --- a/frontend/src/lib/components/assets/AssetGraph/AssetNode.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/AssetNode.svelte @@ -171,14 +171,16 @@ noAssetName?: string /** Assets-only: the node is only its script's banner. */ bannerOnly?: boolean + /** Assets-only: a script folded into the node has an error. */ + scriptError?: boolean /** No script in the workspace writes it; a flow's write fires no asset trigger. */ neverWritten?: boolean /** 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 + /** Assets-only view: the producing script and trigger no other asset + * shares, which the menu offers to delete one by one or together. */ + upstreamDelete?: import('./assetsOnlyView').AssetUpstreamDelete + onDeleteUpstream?: (target: import('./assetsOnlyView').AssetUpstreamDelete) => void deleteVerb?: 'Delete' | 'Archive' /** Why nothing can be deleted, when the producer is shared. */ deleteBlocked?: string @@ -229,15 +231,8 @@ // and a step downstream of what nothing writes would never run on its writes. let showAdd = $derived(data.onAddScript != undefined && !data.error && !data.neverWritten) let menuItems: Item[] = $derived( - data.onDeleteUpstream - ? [ - { - displayName: `${data.deleteVerb ?? 'Delete'} ${data.deleteCount} item${data.deleteCount === 1 ? '' : 's'}`, - icon: Trash2, - type: 'delete' as const, - action: () => data.onDeleteUpstream?.() - } - ] + data.onDeleteUpstream && data.upstreamDelete + ? deleteItems(data.upstreamDelete, data.onDeleteUpstream) : data.deleteBlocked ? [ { @@ -248,6 +243,42 @@ ] : [] ) + const lastSegment = (p: string) => p.split('/').pop() ?? p + // A script is archived rather than deleted when the user may not delete it; + // a trigger is always deleted. + function deleteItems( + t: import('./assetsOnlyView').AssetUpstreamDelete, + run: (target: import('./assetsOnlyView').AssetUpstreamDelete) => void + ): Item[] { + const items: Item[] = [] + if (t.script) { + const script = t.script + items.push({ + displayName: `${data.deleteVerb ?? 'Delete'} script ${lastSegment(script.path)}`, + icon: Trash2, + type: 'delete' as const, + action: () => run({ script }) + }) + } + if (t.trigger) { + const trigger = t.trigger + items.push({ + displayName: `Delete ${trigger.kind} trigger ${lastSegment(trigger.path)}`, + icon: Trash2, + type: 'delete' as const, + action: () => run({ trigger }) + }) + } + if (t.script && t.trigger) { + items.push({ + displayName: `${data.deleteVerb === 'Archive' ? 'Remove' : 'Delete'} all 2 items`, + icon: Trash2, + type: 'delete' as const, + action: () => run(t) + }) + } + return items + } let upstream = $derived(data.upstream) const HEADER_SELECTED = 'bg-surface-accent-selected hover:bg-surface-accent-selected text-accent' const CHIP_SELECTED = @@ -335,7 +366,9 @@ 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' + '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', + data.scriptError && + 'bg-red-100 dark:bg-red-700 text-red-700 dark:text-red-200 hover:bg-red-200 dark:hover:bg-red-600 hover:text-red-800' )} {#if one} {@const isSelected = producer ? !!producer.selected : coSelected} @@ -501,6 +534,7 @@ ? upstreamRow : undefined} header={hasHeader ? scriptHeader : undefined} + headerTone={data.scriptError ? 'error' : undefined} headerSelected={producer ? !!producer.selected : coSelected} width={data.width} surface={upstream ? 'primary' : 'secondary'} diff --git a/frontend/src/lib/components/assets/AssetGraph/PipelineNodeCard.svelte b/frontend/src/lib/components/assets/AssetGraph/PipelineNodeCard.svelte index f9c5b944c4..b6041db145 100644 --- a/frontend/src/lib/components/assets/AssetGraph/PipelineNodeCard.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/PipelineNodeCard.svelte @@ -44,6 +44,8 @@ and the controls hung around the card. header?: Snippet /** The header's script is selected: its edge takes the selected colour. */ headerSelected?: boolean + /** The header's script has an error: its edge goes red. */ + headerTone?: 'error' } let { @@ -61,6 +63,7 @@ and the controls hung around the card. subtitle, header, headerSelected = false, + headerTone, width = NODE.width }: Props = $props() @@ -137,6 +140,7 @@ and the controls hung around the card.
`no-asset:${runnableId}` export const isNoAssetNode = (id: string) => id.startsWith('no-asset:') /** The script and trigger an assets-only asset's delete removes. */ +/** What an assets-only delete removes: the script, its trigger, or both. */ export type AssetUpstreamDelete = { - script: { path: string; unsaved: boolean } + script?: { path: string; unsaved: boolean } trigger?: { kind: NativeTriggerKind; path: string; draft: boolean } } diff --git a/frontend/src/routes/(root)/(logged)/pipeline/[folder]/+page.svelte b/frontend/src/routes/(root)/(logged)/pipeline/[folder]/+page.svelte index de668da1f4..46b11dc76f 100644 --- a/frontend/src/routes/(root)/(logged)/pipeline/[folder]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/pipeline/[folder]/+page.svelte @@ -1282,26 +1282,12 @@ if (!target || !ws) return assetDeleteLoading = true const { script, trigger } = target + const n = (script ? 1 : 0) + (trigger ? 1 : 0) try { // The script first: when it is refused, nothing else has changed. - const deployed = graphRes.current?.runnables.some( - (r) => r.path === script.path && !r.unsaved - ) - if (deployed) { - if (canHardDeleteScripts) { - await ScriptService.deleteScriptByPath({ workspace: ws, path: script.path }) - } else { - await ScriptService.archiveScriptByPath({ workspace: ws, path: script.path }) - } - } - if (pe.drafts.has(script.path)) discardDraft(script.path) - if (deployed) { - pe.discardTriggerDraftsFor(script.path) - forgetPath(script.path) - forgetScriptCache(script.path) - } + if (script) await removeScript(script, ws) } catch (e: any) { - sendUserToast(`Could not remove ${script.path}: ${e?.body ?? e?.message ?? String(e)}`, true) + sendUserToast(`Could not remove ${script?.path}: ${e?.body ?? e?.message ?? String(e)}`, true) assetDeleteLoading = false return } @@ -1311,11 +1297,10 @@ if (pe.triggerDrafts.has(key)) pe.discardTriggerDraft(key) if (!trigger.draft) await deleteTriggerRow(trigger.kind, trigger.path, ws) } - const n = trigger ? 2 : 1 sendUserToast(`Removed ${n} item${n === 1 ? '' : 's'}`) } catch (e: any) { sendUserToast( - `Removed ${script.path}, but not its ${trigger?.kind} trigger ${trigger?.path}: ${e?.body ?? e?.message ?? String(e)}`, + `${script ? `Removed ${script.path}, but not its` : 'Could not remove the'} ${trigger?.kind} trigger ${trigger?.path}: ${e?.body ?? e?.message ?? String(e)}`, true ) } finally { @@ -1324,6 +1309,24 @@ await graphRes.refetch() } } + async function removeScript(script: { path: string }, ws: string) { + const deployed = graphRes.current?.runnables.some( + (r) => r.path === script.path && !r.unsaved + ) + if (deployed) { + if (canHardDeleteScripts) { + await ScriptService.deleteScriptByPath({ workspace: ws, path: script.path }) + } else { + await ScriptService.archiveScriptByPath({ workspace: ws, path: script.path }) + } + } + if (pe.drafts.has(script.path)) discardDraft(script.path) + if (deployed) { + pe.discardTriggerDraftsFor(script.path) + forgetPath(script.path) + forgetScriptCache(script.path) + } + } // ===================== Fixes for misconfigured nodes ===================== // Rewrites a script's annotation header and keeps the result as a draft, opened @@ -3523,7 +3526,11 @@ { @@ -3534,16 +3541,22 @@ {@const t = assetDeleteTarget}

- {canHardDeleteScripts - ? 'The script is deleted, with every version' - : 'The script is archived'}{t.trigger ? ', and its trigger deleted' : ''}. The tables it - wrote stay. + {#if t.script} + {canHardDeleteScripts + ? 'The script is deleted, with every version' + : 'The script is archived'}{t.trigger ? ', and its trigger deleted' : ''}. The tables + it wrote stay. + {:else} + The trigger is deleted: its script stays, and no longer runs this way. + {/if}

    -
  • - {t.script.path} - Script{t.script.unsaved ? ' · draft' : ''} -
  • + {#if t.script} +
  • + {t.script.path} + Script{t.script.unsaved ? ' · draft' : ''} +
  • + {/if} {#if t.trigger}
  • {t.trigger.path}