diff --git a/src/components/shared/ComponentDiff/DiffSection.tsx b/src/components/shared/ComponentDiff/DiffSection.tsx
new file mode 100644
index 000000000..e0fac41b8
--- /dev/null
+++ b/src/components/shared/ComponentDiff/DiffSection.tsx
@@ -0,0 +1,76 @@
+import { Icon } from "@/components/ui/icon";
+import { BlockStack, InlineStack } from "@/components/ui/layout";
+import { Text } from "@/components/ui/typography";
+import type { EntityDiff } from "@/utils/componentSpecDiff";
+
+/**
+ * Renders the lost / added / changed entities of an `EntityDiff` as a compact,
+ * color-coded list. Shared by the legacy and v2 editors (upgrade flow, replace
+ * confirmation, and the edit-component save modal). Only reads `name`, so it
+ * accepts a diff of any name-keyed entity.
+ */
+export function DiffSection({
+ label,
+ diff,
+}: {
+ label: string;
+ diff: EntityDiff<{ name: string }>;
+}) {
+ const hasChanges =
+ diff.lostEntities.length > 0 ||
+ diff.newEntities.length > 0 ||
+ diff.changedEntities.length > 0;
+
+ if (!hasChanges) return null;
+
+ return (
+
+
+ {label} Changes
+
+
+ {diff.lostEntities.map((e) => (
+
+ ))}
+ {diff.newEntities.map((e) => (
+
+ ))}
+ {diff.changedEntities.map((e) => (
+
+ ))}
+
+
+ );
+}
+
+function DiffLine({
+ icon,
+ color,
+ label,
+}: {
+ icon: "Minus" | "Plus" | "RefreshCw";
+ color: string;
+ label: string;
+}) {
+ return (
+
+
+ {label}
+
+ );
+}
diff --git a/src/components/shared/ComponentEditor/ComponentEditorDialog.test.tsx b/src/components/shared/ComponentEditor/ComponentEditorDialog.test.tsx
index d873891f3..60d7fe924 100644
--- a/src/components/shared/ComponentEditor/ComponentEditorDialog.test.tsx
+++ b/src/components/shared/ComponentEditor/ComponentEditorDialog.test.tsx
@@ -372,9 +372,10 @@ describe("", () => {
fireEvent.click(screen.getByRole("button", { name: /Save/i }));
await waitFor(() => {
- // The edited component is handed to the caller to apply.
+ // The edited component is handed to the caller to apply in place.
expect(onComponentSavedMock).toHaveBeenCalledWith(
mockHydratedComponent,
+ "update",
);
expect(onCloseMock).toHaveBeenCalledTimes(1);
});
@@ -386,5 +387,146 @@ describe("", () => {
"success",
);
});
+
+ const renderActionsWith = (label: string, action: string) =>
+ vi.fn(
+ ({
+ onChoose,
+ }: {
+ onChoose: (a: "update" | "import" | "place") => void;
+ }) => (
+
+ ),
+ );
+
+ test("renderSaveActions: Save swaps to the actions view and defers application", async () => {
+ const onCloseMock = vi.fn();
+ const onComponentSavedMock = vi.fn();
+ const renderSaveActions = renderActionsWith("do-update", "update");
+ const mockHydratedComponent = {
+ spec: { implementation: { container: { image: "test" } } },
+ name: "test-component",
+ digest: "abc123",
+ text: "name: test-component",
+ };
+ vi.mocked(hydrateComponentReference).mockResolvedValue(
+ mockHydratedComponent,
+ );
+
+ renderWithProviders(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(
+ screen.getByRole("button", { name: /Save/i }),
+ ).toBeInTheDocument();
+ });
+ fireEvent.click(screen.getByRole("button", { name: /Save/i }));
+
+ // The actions view is shown; nothing is applied or closed yet.
+ await waitFor(() => {
+ expect(
+ screen.getByRole("button", { name: "do-update" }),
+ ).toBeInTheDocument();
+ });
+ expect(onCloseMock).not.toHaveBeenCalled();
+ expect(onComponentSavedMock).not.toHaveBeenCalled();
+
+ // Choosing "update" applies via onComponentSaved and closes.
+ fireEvent.click(screen.getByRole("button", { name: "do-update" }));
+ await waitFor(() => {
+ expect(onComponentSavedMock).toHaveBeenCalledWith(
+ mockHydratedComponent,
+ "update",
+ );
+ expect(onCloseMock).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ test("renderSaveActions: choosing 'import' runs the library path, not onComponentSaved", async () => {
+ const onCloseMock = vi.fn();
+ const onComponentSavedMock = vi.fn();
+ const renderSaveActions = renderActionsWith("do-import", "import");
+ const mockHydratedComponent = {
+ spec: { implementation: { container: { image: "test" } } },
+ name: "test-component",
+ digest: "abc123",
+ text: "name: test-component",
+ };
+ vi.mocked(hydrateComponentReference).mockResolvedValue(
+ mockHydratedComponent,
+ );
+
+ renderWithProviders(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(
+ screen.getByRole("button", { name: /Save/i }),
+ ).toBeInTheDocument();
+ });
+ fireEvent.click(screen.getByRole("button", { name: /Save/i }));
+ fireEvent.click(await screen.findByRole("button", { name: "do-import" }));
+
+ await waitFor(() => {
+ expect(mockAddToComponentLibrary).toHaveBeenCalledWith(
+ mockHydratedComponent,
+ "editor_save",
+ );
+ expect(onCloseMock).toHaveBeenCalledTimes(1);
+ });
+ expect(onComponentSavedMock).not.toHaveBeenCalled();
+ });
+
+ test("renderSaveActions: Back returns to the editor without applying", async () => {
+ const onCloseMock = vi.fn();
+ const onComponentSavedMock = vi.fn();
+ const renderSaveActions = renderActionsWith("do-update", "update");
+ vi.mocked(hydrateComponentReference).mockResolvedValue({
+ spec: { implementation: { container: { image: "test" } } },
+ name: "test-component",
+ digest: "abc123",
+ text: "name: test-component",
+ });
+
+ renderWithProviders(
+ ,
+ );
+
+ fireEvent.click(await screen.findByRole("button", { name: /Save/i }));
+ // Now in the actions view; go Back.
+ fireEvent.click(await screen.findByRole("button", { name: /Back/i }));
+
+ // Editor is shown again; nothing applied or closed.
+ await waitFor(() => {
+ expect(
+ screen.getByRole("button", { name: /Save/i }),
+ ).toBeInTheDocument();
+ });
+ expect(onComponentSavedMock).not.toHaveBeenCalled();
+ expect(onCloseMock).not.toHaveBeenCalled();
+ expect(mockAddToComponentLibrary).not.toHaveBeenCalled();
+ });
});
});
diff --git a/src/components/shared/ComponentEditor/ComponentEditorDialog.tsx b/src/components/shared/ComponentEditor/ComponentEditorDialog.tsx
index 933087ece..64f66b579 100644
--- a/src/components/shared/ComponentEditor/ComponentEditorDialog.tsx
+++ b/src/components/shared/ComponentEditor/ComponentEditorDialog.tsx
@@ -1,5 +1,5 @@
import { useSuspenseQuery } from "@tanstack/react-query";
-import { useEffect, useRef, useState } from "react";
+import { type ReactNode, useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Icon } from "@/components/ui/icon";
@@ -8,6 +8,7 @@ import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Heading, Paragraph } from "@/components/ui/typography";
import useToastNotification from "@/hooks/useToastNotification";
+import { cn } from "@/lib/utils";
import { useAnalytics } from "@/providers/AnalyticsProvider";
import { useComponentLibrary } from "@/providers/ComponentLibraryProvider";
import { hydrateComponentReference } from "@/services/componentService";
@@ -21,6 +22,7 @@ import { FullscreenElement } from "../FullscreenElement";
import { withSuspenseWrapper } from "../SuspenseWrapper";
import { PythonComponentEditor } from "./components/PythonComponentEditor";
import { YamlComponentEditor } from "./components/YamlComponentEditor";
+import type { SaveAction } from "./saveAction";
import type { SupportedTemplate, YamlGeneratorOptions } from "./types";
import { useTemplateCodeByName } from "./useTemplateCodeByName";
@@ -76,6 +78,7 @@ export const ComponentEditorDialog = withSuspenseWrapper(
templateName = "empty",
onClose,
onComponentSaved,
+ renderSaveActions,
}: {
text?: string;
templateName?: SupportedTemplate;
@@ -84,12 +87,27 @@ export const ComponentEditorDialog = withSuspenseWrapper(
* When provided, the editor is being used to edit an existing target (e.g.
* a selected task's component) rather than to import a brand new component
* into the library. The callback receives the hydrated, edited component
- * and is responsible for applying it (and any user feedback). When omitted,
- * the editor falls back to importing the component into the library.
+ * and the chosen {@link SaveAction}, and is responsible for applying it
+ * (and any user feedback). When omitted, the editor falls back to importing
+ * the component into the library.
*/
onComponentSaved?: (
hydratedComponent: HydratedComponentReference,
+ action: SaveAction,
) => void | Promise;
+ /**
+ * When provided, Save swaps the fullscreen surface from the editor to a
+ * full-area "choose what to do" view (rendered by this function), with a
+ * Back affordance to return to the editor. The function receives the
+ * hydrated edit and an `onChoose` callback; `"import"` runs the
+ * library-import path, `"update"`/`"place"` are forwarded to
+ * {@link onComponentSaved}. When omitted, Save imports to the library (or
+ * calls {@link onComponentSaved} with `"update"`) directly.
+ */
+ renderSaveActions?: (args: {
+ hydratedComponent: HydratedComponentReference;
+ onChoose: (action: "update" | "import" | "place") => void;
+ }) => ReactNode;
}) => {
const notify = useToastNotification();
const { track } = useAnalytics();
@@ -99,6 +117,10 @@ export const ComponentEditorDialog = withSuspenseWrapper(
const initialText = text ?? templateCode;
const [componentText, setComponentText] = useState(initialText);
const [errors, setErrors] = useState([]);
+ // When set, the fullscreen surface shows the "choose what to do" view for
+ // this hydrated edit instead of the editor.
+ const [pendingSave, setPendingSave] =
+ useState(null);
const mode = text ? "edit" : "create";
@@ -180,6 +202,16 @@ export const ComponentEditorDialog = withSuspenseWrapper(
setComponentText(value);
};
+ const importToLibrary = async (
+ hydratedComponent: HydratedComponentReference,
+ ) => {
+ await addToComponentLibrary(hydratedComponent, "editor_save");
+ notify(
+ `Component ${hydratedComponent.name} imported successfully`,
+ "success",
+ );
+ };
+
const handleSave = async () => {
const hydratedComponent = await hydrateComponentReference({
text: componentText,
@@ -202,24 +234,53 @@ export const ComponentEditorDialog = withSuspenseWrapper(
updatedAt: Date.now(),
});
+ // When a caller offers a choose-what-to-do step, swap the fullscreen
+ // surface to it instead of finishing the save here.
+ if (renderSaveActions) {
+ setPendingSave(hydratedComponent);
+ return;
+ }
+
+ // Otherwise: editing an existing target updates it in place; the
+ // create/import flow imports to the library.
onClose();
if (onComponentSaved) {
- // Editing an existing target (e.g. a selected task): apply the edit to
- // that target instead of importing a new library component. The caller
- // owns the success/feedback messaging.
- await onComponentSaved(hydratedComponent);
+ await onComponentSaved(hydratedComponent, "update");
+ } else {
+ await importToLibrary(hydratedComponent);
+ }
+
+ track("component_editor.save.completed", {
+ mode,
+ selected_template: templateName,
+ });
+ };
+
+ const handleSaveChoice = async (action: "update" | "import" | "place") => {
+ const hydratedComponent = pendingSave;
+ if (!hydratedComponent) return;
+
+ onClose();
+
+ if (action === "import") {
+ await importToLibrary(hydratedComponent);
} else {
- await addToComponentLibrary(hydratedComponent, "editor_save");
- notify(
- `Component ${hydratedComponent.name} imported successfully`,
- "success",
- );
+ await onComponentSaved?.(hydratedComponent, action);
}
track("component_editor.save.completed", {
mode,
selected_template: templateName,
+ action,
+ });
+ };
+
+ const handleBackToEditor = () => {
+ setPendingSave(null);
+ track("component_editor.save.cancelled", {
+ mode,
+ selected_template: templateName,
});
};
@@ -237,50 +298,81 @@ export const ComponentEditorDialog = withSuspenseWrapper(
return (
-
-
+ {/* Editor view — kept mounted (preserves edits) and faded out while
+ the save-actions view is shown. */}
+
-
- {title}
- {hasTemplate && (
-
- ({templateName} template)
-
- )}
+
+
+ {title}
+ {hasTemplate && (
+
+ ({templateName} template)
+
+ )}
+
+
+
+
+
+
-
-
+
+ {/* Save-actions view — full-area, fades in over the editor. */}
+ {pendingSave && renderSaveActions && (
+
+
- Save
-
-
-
-
-
- {pythonCodeDetection?.isPython ? (
-
- ) : (
-
+
+ Apply your edit
+
+
+ {renderSaveActions({
+ hydratedComponent: pendingSave,
+ onChoose: handleSaveChoice,
+ })}
+
)}
-
+
);
},
diff --git a/src/components/shared/ComponentEditor/SaveActionsView.tsx b/src/components/shared/ComponentEditor/SaveActionsView.tsx
new file mode 100644
index 000000000..db3284bc5
--- /dev/null
+++ b/src/components/shared/ComponentEditor/SaveActionsView.tsx
@@ -0,0 +1,115 @@
+import type { ReactNode } from "react";
+
+import { DiffSection } from "@/components/shared/ComponentDiff/DiffSection";
+import { Button } from "@/components/ui/button";
+import { Icon, type IconName } from "@/components/ui/icon";
+import { BlockStack } from "@/components/ui/layout";
+import { Text } from "@/components/ui/typography";
+import { type EntityDiff, hasIODiff } from "@/utils/componentSpecDiff";
+
+type ChooseableAction = "update" | "import" | "place";
+
+export interface SaveActionsViewProps {
+ taskName: string;
+ inputDiff: EntityDiff<{ name: string }>;
+ outputDiff: EntityDiff<{ name: string }>;
+ /** Whether to offer "Place as a new task". */
+ allowPlace?: boolean;
+ /** Extra content shown above the actions (e.g. v2 predicted issues + preview). */
+ children?: ReactNode;
+ onChoose: (action: ChooseableAction) => void;
+}
+
+/**
+ * The full-area "Apply your edit" interface shown inside the component editor's
+ * fullscreen surface (rendered by `ComponentEditorDialog` when the user hits
+ * Save). It is plain in-flow content — NOT a portalled modal — so it lives in
+ * the editor's stacking context rather than behind it. The editor owns the
+ * Back affordance and title chrome.
+ */
+export function SaveActionsView({
+ taskName,
+ inputDiff,
+ outputDiff,
+ allowPlace = false,
+ children,
+ onChoose,
+}: SaveActionsViewProps) {
+ const showDiff = hasIODiff(inputDiff, outputDiff);
+
+ return (
+
+
+
+ Choose what to do with your changes to “{taskName}”.
+
+
+ {showDiff && (
+
+
+
+
+ )}
+
+ {children}
+
+
+ onChoose("update")}
+ autoFocus
+ />
+ onChoose("import")}
+ />
+ {allowPlace && (
+ onChoose("place")}
+ />
+ )}
+
+
+
+ );
+}
+
+function ActionRow({
+ icon,
+ title,
+ description,
+ onClick,
+ autoFocus,
+}: {
+ icon: IconName;
+ title: string;
+ description: string;
+ onClick: () => void;
+ autoFocus?: boolean;
+}) {
+ return (
+
+ );
+}
diff --git a/src/components/shared/ComponentEditor/saveAction.ts b/src/components/shared/ComponentEditor/saveAction.ts
new file mode 100644
index 000000000..79f33739f
--- /dev/null
+++ b/src/components/shared/ComponentEditor/saveAction.ts
@@ -0,0 +1,9 @@
+/**
+ * What the user chose to do with an edited component definition on Save.
+ *
+ * - `update` — apply the edit to the selected task in place
+ * - `import` — import the edited component into the library as a new component
+ * - `place` — create a new task from the edit, placed near the selected task
+ * - `cancel` — dismiss; keep the editor open
+ */
+export type SaveAction = "update" | "import" | "place" | "cancel";
diff --git a/src/components/shared/TaskDetails/Actions/EditComponentButton.tsx b/src/components/shared/TaskDetails/Actions/EditComponentButton.tsx
index c3d1d4a46..f4ab7cdd6 100644
--- a/src/components/shared/TaskDetails/Actions/EditComponentButton.tsx
+++ b/src/components/shared/TaskDetails/Actions/EditComponentButton.tsx
@@ -4,10 +4,13 @@ import { replaceTaskComponentRef } from "@/components/shared/ReactFlow/FlowCanva
import useToastNotification from "@/hooks/useToastNotification";
import { useComponentSpec } from "@/providers/ComponentSpecProvider";
import type { HydratedComponentReference } from "@/utils/componentSpec";
+import { diffComponentIO } from "@/utils/componentSpecDiff";
import { tracking } from "@/utils/tracking";
import { ActionButton } from "../../Buttons/ActionButton";
import { ComponentEditorDialog } from "../../ComponentEditor/ComponentEditorDialog";
+import type { SaveAction } from "../../ComponentEditor/saveAction";
+import { SaveActionsView } from "../../ComponentEditor/SaveActionsView";
interface EditComponentButtonProps {
componentRef: HydratedComponentReference;
@@ -22,9 +25,17 @@ export const EditComponentButton = ({
const notify = useToastNotification();
const { currentGraphSpec, updateGraphSpec } = useComponentSpec();
+ const editedTask = taskId ? currentGraphSpec?.tasks[taskId] : undefined;
+
const handleComponentSaved = (
hydratedComponent: HydratedComponentReference,
+ action: SaveAction,
) => {
+ if (action !== "update") {
+ // "place" arrives once placement ships; nothing else applies in place.
+ return;
+ }
+
if (!taskId || !currentGraphSpec?.tasks[taskId]) {
notify(
"Could not update the component: the edited task was not found.",
@@ -65,6 +76,24 @@ export const EditComponentButton = ({
text={componentRef.text}
onClose={() => setIsEditDialogOpen(false)}
onComponentSaved={taskId ? handleComponentSaved : undefined}
+ renderSaveActions={
+ taskId
+ ? ({ hydratedComponent, onChoose }) => {
+ const { inputDiff, outputDiff } = diffComponentIO(
+ editedTask?.componentRef.spec,
+ hydratedComponent.spec,
+ );
+ return (
+
+ );
+ }
+ : undefined
+ }
/>
)}
>
diff --git a/src/routes/v2/pages/Editor/components/FlowCanvas/components/ReplaceConfirmationContent.tsx b/src/routes/v2/pages/Editor/components/FlowCanvas/components/ReplaceConfirmationContent.tsx
index e75de9588..d4d4c1e7d 100644
--- a/src/routes/v2/pages/Editor/components/FlowCanvas/components/ReplaceConfirmationContent.tsx
+++ b/src/routes/v2/pages/Editor/components/FlowCanvas/components/ReplaceConfirmationContent.tsx
@@ -1,9 +1,9 @@
+import { DiffSection } from "@/components/shared/ComponentDiff/DiffSection";
import { Icon } from "@/components/ui/icon";
import { BlockStack, InlineStack } from "@/components/ui/layout";
import { Text } from "@/components/ui/typography";
import type { InputSpec } from "@/models/componentSpec";
import type { OutputSpec } from "@/models/componentSpec/entities/types";
-import { DiffSection } from "@/routes/v2/pages/Editor/components/UpgradeComponents/components/UpgradeCandidateDetail";
import type { EntityDiff } from "@/routes/v2/pages/Editor/store/actions/task.utils";
interface ReplaceConfirmationContentProps {
diff --git a/src/routes/v2/pages/Editor/components/UpgradeComponents/components/UpgradeCandidateDetail.tsx b/src/routes/v2/pages/Editor/components/UpgradeComponents/components/UpgradeCandidateDetail.tsx
index eb63bf654..a4618e4da 100644
--- a/src/routes/v2/pages/Editor/components/UpgradeComponents/components/UpgradeCandidateDetail.tsx
+++ b/src/routes/v2/pages/Editor/components/UpgradeComponents/components/UpgradeCandidateDetail.tsx
@@ -1,17 +1,13 @@
+import { DiffSection } from "@/components/shared/ComponentDiff/DiffSection";
import { Badge } from "@/components/ui/badge";
import { Icon } from "@/components/ui/icon";
import { BlockStack, InlineStack } from "@/components/ui/layout";
import { Text } from "@/components/ui/typography";
import type { ValidationIssue } from "@/models/componentSpec";
-import type {
- InputSpec,
- OutputSpec,
-} from "@/models/componentSpec/entities/types";
import {
candidateHasIssues,
type UpgradeCandidate,
} from "@/routes/v2/pages/Editor/components/UpgradeComponents/types";
-import type { EntityDiff } from "@/routes/v2/pages/Editor/store/actions/task.utils";
function truncateDigest(digest: string): string {
return digest.length > 8 ? digest.substring(0, 8) : digest;
@@ -32,72 +28,6 @@ function EmptyDetail() {
);
}
-export function DiffSection({
- label,
- diff,
-}: {
- label: string;
- diff: EntityDiff;
-}) {
- const hasChanges =
- diff.lostEntities.length > 0 ||
- diff.newEntities.length > 0 ||
- diff.changedEntities.length > 0;
-
- if (!hasChanges) return null;
-
- return (
-
-
- {label} Changes
-
-
- {diff.lostEntities.map((e) => (
-
- ))}
- {diff.newEntities.map((e) => (
-
- ))}
- {diff.changedEntities.map((e) => (
-
- ))}
-
-
- );
-}
-
-function DiffLine({
- icon,
- color,
- label,
-}: {
- icon: "Minus" | "Plus" | "RefreshCw";
- color: string;
- label: string;
-}) {
- return (
-
-
- {label}
-
- );
-}
-
function PredictedIssuesSection({ issues }: { issues: ValidationIssue[] }) {
if (issues.length === 0) return null;
diff --git a/src/routes/v2/pages/Editor/store/actions/task.utils.ts b/src/routes/v2/pages/Editor/store/actions/task.utils.ts
index 84c222231..f919f7fbb 100644
--- a/src/routes/v2/pages/Editor/store/actions/task.utils.ts
+++ b/src/routes/v2/pages/Editor/store/actions/task.utils.ts
@@ -6,12 +6,9 @@ import type {
import type { OutputSpec } from "@/models/componentSpec/entities/types";
import { isInputRequired } from "@/models/componentSpec/validation/validateSpec";
import type { LostBinding } from "@/routes/v2/pages/Editor/components/UpgradeComponents/types";
+import { diffComponentIO, type EntityDiff } from "@/utils/componentSpecDiff";
-export interface EntityDiff {
- lostEntities: T[];
- newEntities: T[];
- changedEntities: T[];
-}
+export type { EntityDiff };
export type DiffStatus = "unchanged" | "lost" | "new" | "changed";
@@ -55,61 +52,11 @@ interface ComponentSpecDiff {
outputDiff: EntityDiff;
}
-const EMPTY_DIFF: EntityDiff = {
- lostEntities: [],
- newEntities: [],
- changedEntities: [],
-};
-
export function computeDiffComponentSpecs(
oldSpec: ComponentSpecJson | undefined,
newSpec: ComponentSpecJson | undefined,
): ComponentSpecDiff {
- const inputDiff = computeDiff(
- oldSpec?.inputs,
- newSpec?.inputs,
- (a, b) => a.type === b.type,
- );
- const outputDiff = computeDiff(
- oldSpec?.outputs,
- newSpec?.outputs,
- (a, b) => a.type === b.type,
- );
- return { inputDiff, outputDiff };
-}
-
-function computeDiff(
- prevEntities: TEntity[] | undefined,
- currentEntities: TEntity[] | undefined,
- isEqual: (oldEntity: TEntity, newEntity: TEntity) => boolean,
-): EntityDiff {
- if (!prevEntities || !currentEntities) return EMPTY_DIFF;
-
- const newEntitiesIndex = new Map(currentEntities.map((i) => [i.name, i]));
- const oldEntitiesIndex = new Map(prevEntities.map((i) => [i.name, i]));
-
- const oldEntityNames = new Set(oldEntitiesIndex.keys());
- const newEntityNames = new Set(newEntitiesIndex.keys());
-
- const lostEntities = [...oldEntityNames.difference(newEntityNames)]
- .map((name) => oldEntitiesIndex.get(name))
- .filter((e) => e !== undefined);
-
- const newEntities = [...newEntityNames.difference(oldEntityNames)]
- .map((name) => newEntitiesIndex.get(name))
- .filter((e) => e !== undefined);
-
- const changedEntities = [...oldEntityNames.intersection(newEntityNames)]
- .map((name) => {
- const oldEntity = oldEntitiesIndex.get(name);
- const newEntity = newEntitiesIndex.get(name);
- if (!oldEntity || !newEntity) return undefined;
- if (isEqual(oldEntity, newEntity)) return undefined;
- return newEntity;
- })
- .filter((e) => e !== undefined);
-
- return { lostEntities, newEntities, changedEntities };
+ return diffComponentIO(oldSpec, newSpec);
}
/**
diff --git a/src/utils/componentSpecDiff.test.ts b/src/utils/componentSpecDiff.test.ts
new file mode 100644
index 000000000..5b9dcac65
--- /dev/null
+++ b/src/utils/componentSpecDiff.test.ts
@@ -0,0 +1,98 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ computeEntityDiff,
+ diffComponentIO,
+ hasIODiff,
+} from "./componentSpecDiff";
+
+describe("computeEntityDiff", () => {
+ const eq = (
+ a: { name: string; type?: string },
+ b: { name: string; type?: string },
+ ) => a.type === b.type;
+
+ it("classifies lost, new, and changed entities", () => {
+ const prev = [
+ { name: "a", type: "String" },
+ { name: "b", type: "String" },
+ { name: "c", type: "Integer" },
+ ];
+ const curr = [
+ { name: "a", type: "String" }, // unchanged
+ { name: "b", type: "Integer" }, // changed (type)
+ { name: "d", type: "String" }, // new
+ ];
+
+ const diff = computeEntityDiff(prev, curr, eq);
+
+ expect(diff.lostEntities.map((e) => e.name)).toEqual(["c"]);
+ expect(diff.newEntities.map((e) => e.name)).toEqual(["d"]);
+ expect(diff.changedEntities.map((e) => e.name)).toEqual(["b"]);
+ });
+
+ it("returns an empty diff when either side is undefined", () => {
+ expect(computeEntityDiff(undefined, [{ name: "a" }], eq)).toEqual({
+ lostEntities: [],
+ newEntities: [],
+ changedEntities: [],
+ });
+ expect(computeEntityDiff([{ name: "a" }], undefined, eq)).toEqual({
+ lostEntities: [],
+ newEntities: [],
+ changedEntities: [],
+ });
+ });
+
+ it("preserves input order", () => {
+ const prev = [{ name: "x" }, { name: "y" }, { name: "z" }];
+ const curr = [{ name: "z" }, { name: "x" }];
+ const diff = computeEntityDiff(prev, curr, () => true);
+ expect(diff.lostEntities.map((e) => e.name)).toEqual(["y"]);
+ });
+});
+
+describe("diffComponentIO", () => {
+ it("diffs inputs and outputs by name and type", () => {
+ const oldSpec = {
+ inputs: [
+ { name: "Limit", type: "Integer" },
+ { name: "Select", type: "String" },
+ ],
+ outputs: [{ name: "Table" }],
+ };
+ const newSpec = {
+ inputs: [
+ { name: "Limit", type: "Integer" },
+ { name: "Format", type: "String" },
+ ],
+ outputs: [{ name: "Table" }, { name: "Schema" }],
+ };
+
+ const { inputDiff, outputDiff } = diffComponentIO(oldSpec, newSpec);
+
+ expect(inputDiff.lostEntities.map((e) => e.name)).toEqual(["Select"]);
+ expect(inputDiff.newEntities.map((e) => e.name)).toEqual(["Format"]);
+ expect(outputDiff.newEntities.map((e) => e.name)).toEqual(["Schema"]);
+ });
+});
+
+describe("hasIODiff", () => {
+ const empty = { lostEntities: [], newEntities: [], changedEntities: [] };
+
+ it("is false when both diffs are empty", () => {
+ expect(hasIODiff(empty, empty)).toBe(false);
+ });
+
+ it("is true when the input diff has any change", () => {
+ expect(hasIODiff({ ...empty, newEntities: [{ name: "x" }] }, empty)).toBe(
+ true,
+ );
+ });
+
+ it("is true when the output diff has any change", () => {
+ expect(hasIODiff(empty, { ...empty, lostEntities: [{ name: "y" }] })).toBe(
+ true,
+ );
+ });
+});
diff --git a/src/utils/componentSpecDiff.ts b/src/utils/componentSpecDiff.ts
new file mode 100644
index 000000000..adc1a9b86
--- /dev/null
+++ b/src/utils/componentSpecDiff.ts
@@ -0,0 +1,93 @@
+/**
+ * Shared component input/output diffing, used by both the legacy and v2
+ * editors to show users what an edited component definition changed.
+ *
+ * The implementation is intentionally portable (plain Map/filter, no
+ * `Set.prototype.difference`/`.intersection`) so it runs on every Node version
+ * the test suite uses, not just browsers.
+ */
+
+export interface EntityDiff {
+ lostEntities: T[];
+ newEntities: T[];
+ changedEntities: T[];
+}
+
+const EMPTY_DIFF: EntityDiff = {
+ lostEntities: [],
+ newEntities: [],
+ changedEntities: [],
+};
+
+/**
+ * Diffs two name-keyed entity lists. Entries present before but not after are
+ * "lost"; present after but not before are "new"; present in both but not
+ * `isEqual` are "changed". Order follows the input arrays.
+ */
+export function computeEntityDiff(
+ prevEntities: readonly T[] | undefined,
+ currentEntities: readonly T[] | undefined,
+ isEqual: (oldEntity: T, newEntity: T) => boolean,
+): EntityDiff {
+ if (!prevEntities || !currentEntities) {
+ return { ...EMPTY_DIFF };
+ }
+
+ const prevByName = new Map(prevEntities.map((e) => [e.name, e]));
+ const currByName = new Map(currentEntities.map((e) => [e.name, e]));
+
+ const lostEntities = prevEntities.filter((e) => !currByName.has(e.name));
+ const newEntities = currentEntities.filter((e) => !prevByName.has(e.name));
+ const changedEntities = currentEntities.filter((e) => {
+ const prev = prevByName.get(e.name);
+ return prev !== undefined && !isEqual(prev, e);
+ });
+
+ return { lostEntities, newEntities, changedEntities };
+}
+
+type IOEntity = { name: string; type?: unknown };
+type ComponentIO = {
+ inputs?: readonly I[];
+ outputs?: readonly O[];
+};
+
+/**
+ * Diffs the inputs and outputs of two component specs. Inputs/outputs are
+ * considered "changed" when their `type` differs. Works structurally against
+ * both the legacy (`@/utils/componentSpec`) and v2 model spec shapes.
+ */
+export function diffComponentIO(
+ oldIO: ComponentIO | undefined,
+ newIO: ComponentIO | undefined,
+): { inputDiff: EntityDiff; outputDiff: EntityDiff } {
+ return {
+ inputDiff: computeEntityDiff(
+ oldIO?.inputs,
+ newIO?.inputs,
+ (a, b) => a.type === b.type,
+ ),
+ outputDiff: computeEntityDiff(
+ oldIO?.outputs,
+ newIO?.outputs,
+ (a, b) => a.type === b.type,
+ ),
+ };
+}
+
+/** True when either diff contains any lost, new, or changed entity. */
+function hasEntityChanges(diff: EntityDiff): boolean {
+ return (
+ diff.lostEntities.length > 0 ||
+ diff.newEntities.length > 0 ||
+ diff.changedEntities.length > 0
+ );
+}
+
+/** True when the input or output diff contains any change. */
+export function hasIODiff(
+ inputDiff: EntityDiff,
+ outputDiff: EntityDiff,
+): boolean {
+ return hasEntityChanges(inputDiff) || hasEntityChanges(outputDiff);
+}