diff --git a/frontend/src/lib/components/assets/AssetGraph/pipelineDelete.test.ts b/frontend/src/lib/components/assets/AssetGraph/pipelineDelete.test.ts new file mode 100644 index 0000000000..1e0c77aa70 --- /dev/null +++ b/frontend/src/lib/components/assets/AssetGraph/pipelineDelete.test.ts @@ -0,0 +1,52 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { DraftService, ScriptService } from '$lib/gen' +import { planPipelineDelete } from './pipelineDelete' + +afterEach(() => vi.restoreAllMocks()) + +describe('planPipelineDelete', () => { + it("takes only the pipeline's own triggers and addresses draft-only nodes by storage key", async () => { + vi.spyOn(ScriptService, 'listRunnables').mockResolvedValue({ + items: [ + { type: 'script', path: 'f/sales/ingest', auto_kind: 'pipeline' }, + { type: 'script', path: 'f/sales/report' }, + { + type: 'script', + path: 'u/alice/draft_abc', + draft_only: true, + draft_path: 'f/sales/unpublished', + auto_kind: 'pipeline' + } + ] + } as any) + vi.spyOn(DraftService, 'getOwnDraft').mockResolvedValue(undefined as any) + const trigger = (runnable_kind: string, runnable_path: string, path: string) => ({ + trigger_kind: 'schedule', + runnable_kind, + runnable_path, + path + }) + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + triggers: [ + trigger('script', 'f/sales/ingest', 'f/sales/ingest_schedule'), + trigger('script', 'f/sales/report', 'f/sales/report_schedule'), + trigger('flow', 'f/sales/ingest', 'f/sales/flow_schedule') + ] + }) + }) + ) + + const plan = await planPipelineDelete('w', 'sales') + + expect(plan.triggers).toEqual([{ kind: 'schedule', path: 'f/sales/ingest_schedule' }]) + expect(plan.scripts).toEqual([ + { path: 'f/sales/ingest', displayPath: 'f/sales/ingest', draftOnly: false }, + { path: 'u/alice/draft_abc', displayPath: 'f/sales/unpublished', draftOnly: true } + ]) + vi.unstubAllGlobals() + }) +}) diff --git a/frontend/src/lib/components/assets/AssetGraph/pipelineDelete.ts b/frontend/src/lib/components/assets/AssetGraph/pipelineDelete.ts index c4c1f7e9ec..7d17dfb35b 100644 --- a/frontend/src/lib/components/assets/AssetGraph/pipelineDelete.ts +++ b/frontend/src/lib/components/assets/AssetGraph/pipelineDelete.ts @@ -26,7 +26,9 @@ async function listPipelineScripts(workspace: string, folder: string): Promise) { const res = await fetch( `${OpenAPI.BASE ?? ''}/w/${workspace}/assets/graph?${new URLSearchParams({ folder })}`, { credentials: 'include' } @@ -35,6 +37,8 @@ async function listNativeTriggers(workspace: string, folder: string) { const graph = (await res.json()) as AssetGraphResponse return graph.triggers.flatMap((t) => t.trigger_kind !== 'asset' && + t.runnable_kind === 'script' && + scripts.has(t.runnable_path) && t.trigger_kind !== 'webhook' && t.trigger_kind !== 'data_upload' && t.path && @@ -45,7 +49,9 @@ async function listNativeTriggers(workspace: string, folder: string) { } export type PipelineDeletePlan = { - scripts: { path: string; draftOnly: boolean }[] + /** `path` is what the API addresses (a draft-only node's storage key); + * `displayPath` is the path the user typed for it. */ + scripts: { path: string; displayPath: string; draftOnly: boolean }[] triggers: { kind: NativeTriggerKind; path: string }[] /** Whether the caller has an unsaved pipeline draft for this folder. */ hasDraft: boolean @@ -56,9 +62,10 @@ export async function planPipelineDelete( workspace: string, folder: string ): Promise { - const [scripts, triggers, draft] = await Promise.all([ - listPipelineScripts(workspace, folder), - listNativeTriggers(workspace, folder), + const scripts = await listPipelineScripts(workspace, folder) + const deployed = new Set(scripts.filter((s) => !s.draft_only).map((s) => s.path)) + const [triggers, draft] = await Promise.all([ + listNativeTriggers(workspace, folder, deployed), DraftService.getOwnDraft({ workspace, kind: PIPELINE_DRAFT_KIND, @@ -67,7 +74,8 @@ export async function planPipelineDelete( ]) return { scripts: scripts.map((s) => ({ - path: s.draft_only ? s.draft_path || s.path : s.path, + path: s.path, + displayPath: (s.draft_only && s.draft_path) || s.path, draftOnly: !!s.draft_only })), triggers, @@ -110,7 +118,7 @@ export async function deletePipeline( } removed++ } catch (e) { - fail(s.path, e) + fail(s.displayPath, e) } } if (plan.hasDraft) { diff --git a/frontend/src/lib/components/common/table/PipelineRow.svelte b/frontend/src/lib/components/common/table/PipelineRow.svelte index 1c5ed22901..f196356683 100644 --- a/frontend/src/lib/components/common/table/PipelineRow.svelte +++ b/frontend/src/lib/components/common/table/PipelineRow.svelte @@ -110,13 +110,13 @@ {#if plan.scripts.some((s) => !s.draftOnly)} {@render group( hardDelete ? 'Scripts deleted' : 'Scripts archived', - plan.scripts.filter((s) => !s.draftOnly).map((s) => s.path) + plan.scripts.filter((s) => !s.draftOnly).map((s) => s.displayPath) )} {/if} {#if plan.scripts.some((s) => s.draftOnly)} {@render group( 'Draft scripts discarded', - plan.scripts.filter((s) => s.draftOnly).map((s) => s.path) + plan.scripts.filter((s) => s.draftOnly).map((s) => s.displayPath) )} {/if} {#if plan.triggers.length}