From e747268a0bebc3f34f28ff2f74db9ed25ddb9903 Mon Sep 17 00:00:00 2001 From: Morgan Wowk Date: Thu, 4 Jun 2026 18:13:47 -0700 Subject: [PATCH] feat: harmonize the edit-save UX with the upgrade feature (legacy editor) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligns the "Edit Component" save flow with the existing component-upgrade / replace flows so the two read as one family (the stack will be reviewed by the upgrade authors). An edit is, in effect, an upgrade to a hand-edited version. - Shared `ComponentEditSummary` (`src/components/shared/ComponentDiff/`): renders the current → new digest transition (reusing `TrimmedDigest`) and the breaking-change warning — the exact copy from the v2 `ReplaceConfirmationContent` ("Some inputs or outputs will be removed, and their connections will be lost.") — above the shared `DiffSection`s. Used by both editors' save modals. - `replaceTaskComponentRef` now seeds default arguments for newly added inputs, matching the upgrade flow's `replaceTask` (it already pruned removed-input args and orphaned downstream output bindings). - The legacy choose-action modal shows the `ComponentEditSummary` (digest transition + breaking warning) above the diff. - Analytics: emit `pipeline_editor.component.edited` (sibling of `.upgraded` / `.replaced`) on update/place with `componentMetadata(...)` + action. Deliberate divergences from upgrade (kept on purpose): edit updates in place (no task rename, unlike `replaceTaskNode`) and is undoable (so no "This cannot be undone" copy). Tests: new-input default seeding in `replaceTaskComponentRef`; `ComponentEditSummary` warning/diff rendering. --- .../ComponentEditSummary.test.tsx | 40 ++++++++++ .../ComponentDiff/ComponentEditSummary.tsx | 80 +++++++++++++++++++ .../ComponentEditor/SaveActionsView.tsx | 29 ++++--- .../utils/replaceTaskComponentRef.test.ts | 33 ++++++++ .../utils/replaceTaskComponentRef.ts | 24 +++++- .../Actions/EditComponentButton.tsx | 17 ++++ 6 files changed, 209 insertions(+), 14 deletions(-) create mode 100644 src/components/shared/ComponentDiff/ComponentEditSummary.test.tsx create mode 100644 src/components/shared/ComponentDiff/ComponentEditSummary.tsx diff --git a/src/components/shared/ComponentDiff/ComponentEditSummary.test.tsx b/src/components/shared/ComponentDiff/ComponentEditSummary.test.tsx new file mode 100644 index 000000000..636f89361 --- /dev/null +++ b/src/components/shared/ComponentDiff/ComponentEditSummary.test.tsx @@ -0,0 +1,40 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import type { EntityDiff } from "@/utils/componentSpecDiff"; + +import { ComponentEditSummary } from "./ComponentEditSummary"; + +const empty: EntityDiff<{ name: string }> = { + lostEntities: [], + newEntities: [], + changedEntities: [], +}; + +const BREAKING = "Some inputs or outputs will be removed"; + +describe("ComponentEditSummary", () => { + it("shows the breaking-change warning and removed input when inputs are lost", () => { + render( + , + ); + + expect(screen.getByText(new RegExp(BREAKING))).toBeInTheDocument(); + expect(screen.getByText("Removed: threshold")).toBeInTheDocument(); + }); + + it("omits the breaking-change warning when nothing is removed", () => { + render( + , + ); + + expect(screen.queryByText(new RegExp(BREAKING))).not.toBeInTheDocument(); + expect(screen.getByText("Added: max_rows")).toBeInTheDocument(); + }); +}); diff --git a/src/components/shared/ComponentDiff/ComponentEditSummary.tsx b/src/components/shared/ComponentDiff/ComponentEditSummary.tsx new file mode 100644 index 000000000..feb8ada7e --- /dev/null +++ b/src/components/shared/ComponentDiff/ComponentEditSummary.tsx @@ -0,0 +1,80 @@ +import { DiffSection } from "@/components/shared/ComponentDiff/DiffSection"; +import { TrimmedDigest } from "@/components/shared/ManageComponent/TrimmedDigest"; +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"; + +interface ComponentEditSummaryProps { + /** Digest of the component currently on the task. */ + currentDigest?: string; + /** Digest of the edited component being applied. */ + newDigest?: string; + inputDiff: EntityDiff<{ name: string }>; + outputDiff: EntityDiff<{ name: string }>; + /** + * When false, the lost/new/changed `DiffSection`s are omitted (the digest + * transition + breaking warning still show). Used where a richer preview + * renders the per-port diff instead. Defaults to true. + */ + showDiffList?: boolean; +} + +/** + * Summarizes what an edited component changes, in the same visual language as + * the component-upgrade / replace flows: a current → new digest transition, a + * breaking-change warning when inputs/outputs are removed, and the shared + * lost/new/changed `DiffSection`s. Shared by the legacy and v2 save modals. + */ +export function ComponentEditSummary({ + currentDigest, + newDigest, + inputDiff, + outputDiff, + showDiffList = true, +}: ComponentEditSummaryProps) { + const hasBreakingChanges = + inputDiff.lostEntities.length > 0 || outputDiff.lostEntities.length > 0; + const showDigests = + !!currentDigest && !!newDigest && currentDigest !== newDigest; + + return ( + + {showDigests && ( + + + + + + )} + + {hasBreakingChanges && ( + + + + Some inputs or outputs will be removed, and their connections will + be lost. + + + )} + + {showDiffList && ( + <> + + + + )} + + ); +} diff --git a/src/components/shared/ComponentEditor/SaveActionsView.tsx b/src/components/shared/ComponentEditor/SaveActionsView.tsx index db3284bc5..a7c315092 100644 --- a/src/components/shared/ComponentEditor/SaveActionsView.tsx +++ b/src/components/shared/ComponentEditor/SaveActionsView.tsx @@ -1,20 +1,27 @@ import type { ReactNode } from "react"; -import { DiffSection } from "@/components/shared/ComponentDiff/DiffSection"; +import { ComponentEditSummary } from "@/components/shared/ComponentDiff/ComponentEditSummary"; 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"; +import type { EntityDiff } from "@/utils/componentSpecDiff"; type ChooseableAction = "update" | "import" | "place"; export interface SaveActionsViewProps { taskName: string; + currentDigest?: string; + newDigest?: string; inputDiff: EntityDiff<{ name: string }>; outputDiff: EntityDiff<{ name: string }>; /** Whether to offer "Place as a new task". */ allowPlace?: boolean; + /** + * When false, the lost/new/changed diff list is hidden (e.g. the v2 editor + * shows a ghost-diff preview card instead). Defaults to true. + */ + showDiffList?: boolean; /** Extra content shown above the actions (e.g. v2 predicted issues + preview). */ children?: ReactNode; onChoose: (action: ChooseableAction) => void; @@ -29,14 +36,15 @@ export interface SaveActionsViewProps { */ export function SaveActionsView({ taskName, + currentDigest, + newDigest, inputDiff, outputDiff, allowPlace = false, + showDiffList = true, children, onChoose, }: SaveActionsViewProps) { - const showDiff = hasIODiff(inputDiff, outputDiff); - return (
@@ -44,12 +52,13 @@ export function SaveActionsView({ Choose what to do with your changes to “{taskName}”. - {showDiff && ( - - - - - )} + {children} diff --git a/src/components/shared/ReactFlow/FlowCanvas/utils/replaceTaskComponentRef.test.ts b/src/components/shared/ReactFlow/FlowCanvas/utils/replaceTaskComponentRef.test.ts index 58dc1555a..1cd3075e2 100644 --- a/src/components/shared/ReactFlow/FlowCanvas/utils/replaceTaskComponentRef.test.ts +++ b/src/components/shared/ReactFlow/FlowCanvas/utils/replaceTaskComponentRef.test.ts @@ -145,6 +145,39 @@ describe("replaceTaskComponentRef", () => { expect(updatedGraphSpec.tasks.train.arguments).toEqual({}); }); + it("seeds default arguments for newly added inputs", () => { + const graphSpec = baseGraphSpec(); + const refWithNewInput: ComponentReference = { + name: "Chicago Taxi Trips dataset", + digest: "new-digest", + spec: { + name: "Chicago Taxi Trips dataset", + inputs: [ + { name: "Limit", type: "Integer", default: "1000" }, + { name: "Select", type: "String" }, + { name: "Format", type: "String", default: "csv" }, + { name: "NoDefault", type: "String" }, + ], + outputs: [{ name: "Table" }], + implementation: { container: { image: "alpine/curl" } }, + }, + }; + + const { updatedGraphSpec } = replaceTaskComponentRef( + "dataset", + refWithNewInput, + graphSpec, + ); + + // New input with a default is seeded; one without a default is not; and + // existing arguments are preserved. + expect(updatedGraphSpec.tasks.dataset.arguments).toEqual({ + Limit: { graphInput: { inputName: "Input" } }, + Select: "tips,trip_seconds", + Format: "csv", + }); + }); + it("returns the graph unchanged when the task does not exist", () => { const graphSpec = baseGraphSpec(); const fixedRef = { ...taxiRef("fixed"), digest: "new-digest" }; diff --git a/src/components/shared/ReactFlow/FlowCanvas/utils/replaceTaskComponentRef.ts b/src/components/shared/ReactFlow/FlowCanvas/utils/replaceTaskComponentRef.ts index 5a448cac7..2940b24c4 100644 --- a/src/components/shared/ReactFlow/FlowCanvas/utils/replaceTaskComponentRef.ts +++ b/src/components/shared/ReactFlow/FlowCanvas/utils/replaceTaskComponentRef.ts @@ -19,9 +19,10 @@ import { deepClone } from "@/utils/deepClone"; * renaming would re-key the task to a new unique id derived from the component * name and is surprising for an in-place edit. * - * Wiring is preserved with two safe exceptions, mirroring the upgrade flow: + * Wiring is reconciled the same way the upgrade flow's `replaceTask` does: * - argument bindings on this task for inputs that no longer exist in the * edited component are dropped (and reported via `lostInputs`); + * - newly added inputs are seeded with their default argument value; * - argument bindings on downstream tasks that consume an output this * component no longer produces are dropped, to avoid dangling references. * @@ -41,9 +42,9 @@ export const replaceTaskComponentRef = ( } const oldInputs = task.componentRef.spec?.inputs ?? []; - const newInputNames = new Set( - (newComponentRef.spec?.inputs ?? []).map((input) => input.name), - ); + const oldInputNames = new Set(oldInputs.map((input) => input.name)); + const newInputs = newComponentRef.spec?.inputs ?? []; + const newInputNames = new Set(newInputs.map((input) => input.name)); const newOutputNames = new Set( (newComponentRef.spec?.outputs ?? []).map((output) => output.name), ); @@ -68,6 +69,21 @@ export const replaceTaskComponentRef = ( ); } + // Seed default argument values for newly added inputs (parity with the + // upgrade flow's `replaceTask`). + const newlyAddedInputs = newInputs.filter( + (input) => !oldInputNames.has(input.name), + ); + if (newlyAddedInputs.some((input) => input.default !== undefined)) { + const args = task.arguments ?? {}; + for (const input of newlyAddedInputs) { + if (input.default !== undefined && args[input.name] === undefined) { + args[input.name] = input.default; + } + } + task.arguments = args; + } + // Drop downstream argument bindings that consume an output this component no // longer produces. Outputs that still exist keep working because the taskId // is unchanged. diff --git a/src/components/shared/TaskDetails/Actions/EditComponentButton.tsx b/src/components/shared/TaskDetails/Actions/EditComponentButton.tsx index 9729918a8..d1a908de5 100644 --- a/src/components/shared/TaskDetails/Actions/EditComponentButton.tsx +++ b/src/components/shared/TaskDetails/Actions/EditComponentButton.tsx @@ -7,6 +7,7 @@ import type { Bounds } from "@/components/shared/ReactFlow/FlowCanvas/utils/geom import { replaceTaskComponentRef } from "@/components/shared/ReactFlow/FlowCanvas/utils/replaceTaskComponentRef"; import { useNodesOverlay } from "@/components/shared/ReactFlow/NodesOverlay/NodesOverlayProvider"; import useToastNotification from "@/hooks/useToastNotification"; +import { useAnalytics } from "@/providers/AnalyticsProvider"; import { useComponentSpec } from "@/providers/ComponentSpecProvider"; import { extractPositionFromAnnotations } from "@/utils/annotations"; import { @@ -16,6 +17,7 @@ import { type TaskSpec, } from "@/utils/componentSpec"; import { diffComponentIO } from "@/utils/componentSpecDiff"; +import { componentMetadata } from "@/utils/componentTracking"; import { DEFAULT_NODE_DIMENSIONS } from "@/utils/constants"; import { taskIdToNodeId } from "@/utils/nodes/nodeIdUtils"; import { tracking } from "@/utils/tracking"; @@ -39,6 +41,7 @@ export const EditComponentButton = ({ }: EditComponentButtonProps) => { const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); const notify = useToastNotification(); + const { track } = useAnalytics(); const { currentGraphSpec, updateGraphSpec } = useComponentSpec(); const { getNodes } = useReactFlow(); const { fitNodeIntoView, selectNode, notifyNode } = useNodesOverlay(); @@ -62,6 +65,12 @@ export const EditComponentButton = ({ updateGraphSpec(updatedGraphSpec); + track("pipeline_editor.component.edited", { + ...componentMetadata(hydratedComponent, "user"), + action: "update", + lost_inputs_count: lostInputs.length, + }); + if (lostInputs.length > 0) { const inputNames = lostInputs.map((input) => input.name).join(", "); notify( @@ -147,6 +156,12 @@ export const EditComponentButton = ({ } updateGraphSpec(updatedWrapper.implementation.graph); + + track("pipeline_editor.component.edited", { + ...componentMetadata(hydratedComponent, "user"), + action: "place", + }); + notify("Task added", "success"); // The new node mounts asynchronously; wait for it, then reveal + spotlight. @@ -194,6 +209,8 @@ export const EditComponentButton = ({ return (