Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions src/routes/v2/pages/Editor/lineage/ReconcileSiblingsDialog.tsx
Original file line number Diff line number Diff line change
@@ -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<Set<string>>(
() => 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 (
<AlertDialog
open
onOpenChange={(isOpen) => {
if (!isOpen) onCancel();
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Update matching tasks?</AlertDialogTitle>
<AlertDialogDescription>
{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.
</AlertDialogDescription>
</AlertDialogHeader>

<BlockStack gap="1" className="max-h-64 overflow-y-auto py-1">
{matches.map((match) => (
<label
key={match.taskId}
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 hover:bg-accent"
>
<Checkbox
checked={selected.has(match.taskId)}
onCheckedChange={(checked) =>
toggle(match.taskId, checked === true)
}
/>
<span className="flex min-w-0 flex-col">
<Text size="sm" className="truncate">
{match.taskName}
</Text>
{match.subgraphPath.length > 0 && (
<InlineStack gap="1" blockAlign="center">
<Icon
name="Workflow"
size="xs"
className="shrink-0 text-muted-foreground"
/>
<Text size="xs" tone="subdued" className="truncate">
{match.subgraphPath.join(" / ")}
</Text>
</InlineStack>
)}
</span>
</label>
))}
</BlockStack>

<AlertDialogFooter>
<AlertDialogCancel onClick={onCancel}>Not now</AlertDialogCancel>
<AlertDialogAction
disabled={count === 0}
onClick={() => onConfirm([...selected])}
>
Update {count} {count === 1 ? "task" : "tasks"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
45 changes: 45 additions & 0 deletions src/routes/v2/pages/Editor/lineage/findTaskContext.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
27 changes: 27 additions & 0 deletions src/routes/v2/pages/Editor/lineage/findTaskContext.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -60,6 +66,10 @@ export const TaskDetails = observer(function TaskDetails({

const [detailsTab, setDetailsTab] = useState<DetailsTab>("arguments");
const [isRenaming, setIsRenaming] = useState(false);
const [reconcile, setReconcile] = useState<{
component: HydratedComponentReference;
matches: LineageUsage[];
} | null>(null);
const renameInputRef = useRef<HTMLInputElement>(null);

useEffect(() => {
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -366,6 +414,15 @@ export const TaskDetails = observer(function TaskDetails({
<TaskActionsBar entityId={entityId} />
</InlineStack>
</BlockStack>

{reconcile && (
<ReconcileSiblingsDialog
componentName={task.name}
matches={reconcile.matches}
onConfirm={handleReconcileConfirm}
onCancel={() => setReconcile(null)}
/>
)}
</BlockStack>
);
});
Loading