diff --git a/src/routes/v2/pages/Editor/lineage/ReconcileSiblingsDialog.tsx b/src/routes/v2/pages/Editor/lineage/ReconcileSiblingsDialog.tsx new file mode 100644 index 000000000..96b171e7b --- /dev/null +++ b/src/routes/v2/pages/Editor/lineage/ReconcileSiblingsDialog.tsx @@ -0,0 +1,121 @@ +import { useState } from "react"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Icon } from "@/components/ui/icon"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { Text } from "@/components/ui/typography"; + +import type { LineageUsage } from "./collectLineageUsages"; + +interface ReconcileSiblingsDialogProps { + componentName: string; + matches: LineageUsage[]; + onConfirm: (taskIds: string[]) => void; + onCancel: () => void; +} + +/** + * Shown after an in-place component edit when other tasks in the pipeline share + * the same component origin. Offers to update the matching tasks to the edited + * version too, with per-task selection. Mounted only while active, so selection + * state resets for each prompt. + */ +export function ReconcileSiblingsDialog({ + componentName, + matches, + onConfirm, + onCancel, +}: ReconcileSiblingsDialogProps) { + const [selected, setSelected] = useState>( + () => new Set(matches.map((m) => m.taskId)), + ); + + const toggle = (taskId: string, checked: boolean) => { + setSelected((prev) => { + const next = new Set(prev); + if (checked) { + next.add(taskId); + } else { + next.delete(taskId); + } + return next; + }); + }; + + const count = selected.size; + + return ( + { + if (!isOpen) onCancel(); + }} + > + + + Update matching tasks? + + {matches.length} other{" "} + {matches.length === 1 ? "task uses" : "tasks use"} the same origin + as “{componentName}”. Update {matches.length === 1 ? "it" : "them"}{" "} + to your edited version too? Inputs or outputs that no longer exist + will be removed, along with their connections. + + + + + {matches.map((match) => ( + + ))} + + + + Not now + onConfirm([...selected])} + > + Update {count} {count === 1 ? "task" : "tasks"} + + + + + ); +} diff --git a/src/routes/v2/pages/Editor/lineage/findTaskContext.test.ts b/src/routes/v2/pages/Editor/lineage/findTaskContext.test.ts new file mode 100644 index 000000000..4b57eabee --- /dev/null +++ b/src/routes/v2/pages/Editor/lineage/findTaskContext.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; + +import { ComponentSpec } from "@/models/componentSpec/entities/componentSpec"; +import { Task } from "@/models/componentSpec/entities/task"; + +import { findTaskContext } from "./findTaskContext"; + +const task = ($id: string, name: string, subgraphSpec?: ComponentSpec) => + new Task({ $id, name, componentRef: {}, subgraphSpec }); + +describe("findTaskContext", () => { + it("finds a root-level task in the root spec", () => { + const root = new ComponentSpec({ + name: "Root", + tasks: [task("a", "A"), task("b", "B")], + }); + + const ctx = findTaskContext(root, "b"); + + expect(ctx?.spec).toBe(root); + expect(ctx?.task.name).toBe("B"); + }); + + it("finds a nested task and returns its containing subgraph spec", () => { + const sub = new ComponentSpec({ + name: "Sub", + tasks: [task("nested", "Nested")], + }); + const root = new ComponentSpec({ + name: "Root", + tasks: [task("group", "Group", sub)], + }); + + const ctx = findTaskContext(root, "nested"); + + expect(ctx?.spec).toBe(sub); + expect(ctx?.task.name).toBe("Nested"); + }); + + it("returns undefined when the task is absent", () => { + const root = new ComponentSpec({ name: "Root", tasks: [task("a", "A")] }); + + expect(findTaskContext(root, "missing")).toBeUndefined(); + }); +}); diff --git a/src/routes/v2/pages/Editor/lineage/findTaskContext.ts b/src/routes/v2/pages/Editor/lineage/findTaskContext.ts new file mode 100644 index 000000000..031c116f9 --- /dev/null +++ b/src/routes/v2/pages/Editor/lineage/findTaskContext.ts @@ -0,0 +1,27 @@ +import type { ComponentSpec, Task } from "@/models/componentSpec"; + +export interface TaskContext { + /** The spec whose direct `tasks` array contains the task. */ + spec: ComponentSpec; + task: Task; +} + +/** + * Locate a task by id anywhere in a spec tree, recursing through subgraphs. + * Returns the task together with the (sub)spec that directly contains it, so + * callers can mutate it in the right scope (e.g. `replaceTask` cleans up the + * containing spec's bindings). + */ +export function findTaskContext( + rootSpec: ComponentSpec, + taskId: string, +): TaskContext | undefined { + for (const task of rootSpec.tasks) { + if (task.$id === taskId) return { spec: rootSpec, task }; + if (task.subgraphSpec) { + const found = findTaskContext(task.subgraphSpec, taskId); + if (found) return found; + } + } + return undefined; +} diff --git a/src/routes/v2/pages/Editor/nodes/TaskNode/context/TaskDetails/TaskDetails.tsx b/src/routes/v2/pages/Editor/nodes/TaskNode/context/TaskDetails/TaskDetails.tsx index 8d57bd02d..2203c26c0 100644 --- a/src/routes/v2/pages/Editor/nodes/TaskNode/context/TaskDetails/TaskDetails.tsx +++ b/src/routes/v2/pages/Editor/nodes/TaskNode/context/TaskDetails/TaskDetails.tsx @@ -18,6 +18,12 @@ import { useAnalytics } from "@/providers/AnalyticsProvider"; import { AnnotationsBlock } from "@/routes/v2/pages/Editor/components/AnnotationsBlock/AnnotationsBlock"; import { PredictedIssuesSection } from "@/routes/v2/pages/Editor/components/UpgradeComponents/components/UpgradeCandidateDetail"; import { buildUpgradeCandidateFromResolved } from "@/routes/v2/pages/Editor/components/UpgradeComponents/utils/buildUpgradeCandidateFromResolved"; +import { + collectLineageUsages, + type LineageUsage, +} from "@/routes/v2/pages/Editor/lineage/collectLineageUsages"; +import { findTaskContext } from "@/routes/v2/pages/Editor/lineage/findTaskContext"; +import { ReconcileSiblingsDialog } from "@/routes/v2/pages/Editor/lineage/ReconcileSiblingsDialog"; import { useTaskActions } from "@/routes/v2/pages/Editor/store/actions/useTaskActions"; import { useEditorSession } from "@/routes/v2/pages/Editor/store/EditorSessionContext"; import { useSpec } from "@/routes/v2/shared/providers/SpecContext"; @@ -60,6 +66,10 @@ export const TaskDetails = observer(function TaskDetails({ const [detailsTab, setDetailsTab] = useState("arguments"); const [isRenaming, setIsRenaming] = useState(false); + const [reconcile, setReconcile] = useState<{ + component: HydratedComponentReference; + matches: LineageUsage[]; + } | null>(null); const renameInputRef = useRef(null); useEffect(() => { @@ -140,6 +150,44 @@ export const TaskDetails = observer(function TaskDetails({ } else { notify("Component updated", "success"); } + + // Offer to reconcile other tasks that share this component's origin (incl. + // nested in subgraphs) but haven't been updated to the edited version yet. + const originId = task.annotations.get(LINEAGE_ORIGIN_ANNOTATION)?.originId; + if (originId) { + const matches = collectLineageUsages(spec, originId).filter( + (usage) => + usage.taskId !== task.$id && + usage.digest !== hydratedComponent.digest, + ); + if (matches.length > 0) { + setReconcile({ component: hydratedComponent, matches }); + } + } + }; + + const handleReconcileConfirm = (taskIds: string[]) => { + if (!reconcile) return; + const { component } = reconcile; + + undo.withGroup("Reconcile component", () => { + for (const taskId of taskIds) { + const ctx = findTaskContext(spec, taskId); + if (ctx) replaceTask(ctx.spec, taskId, component); + } + }); + + track("pipeline_editor.component.lineage_reconcile", { + ...componentMetadata(component, "user"), + applied_count: taskIds.length, + matched_count: reconcile.matches.length, + }); + + notify( + `Updated ${taskIds.length} matching ${taskIds.length === 1 ? "task" : "tasks"}.`, + "success", + ); + setReconcile(null); }; const placeAsNewTask = (hydratedComponent: HydratedComponentReference) => { @@ -366,6 +414,15 @@ export const TaskDetails = observer(function TaskDetails({ + + {reconcile && ( + setReconcile(null)} + /> + )} ); });