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) + + )} + + + + + + - - - - - - - {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); +}