Skip to content

Commit 5d5fe22

Browse files
committed
feat: choose-action modal on Save in the v2 component editor
Brings the v2 editor's "Edit Component" Save flow to parity with the legacy editor: instead of silently updating the selected task in place, it opens a small modal to choose what to do with the edit (Update this task / Import to library), showing which inputs/outputs changed. - `ChooseSaveActionDialog` (v2) is opened via the `DialogProvider` (`useDialog().open`), mirroring `ReplaceConfirmationDialog`, and resolves to a shared `SaveAction`. It reuses the shared `DiffSection`. - `TaskDetails` builds `resolveSaveAction` (diffing via the shared `diffComponentIO`, mapping a cancelled dialog to `"cancel"` via `convertCancelErrorTo`) and gates `handleComponentSaved` on `"update"`, applying it through the existing `replaceTask` store action. `ComponentRefBar` forwards `resolveSaveAction` to the editor dialog. The "Place as a new task" option is gated behind `allowPlace`, enabled in a later branch. No behavior changes when no resolver is supplied.
1 parent 4082941 commit 5d5fe22

3 files changed

Lines changed: 159 additions & 0 deletions

File tree

src/routes/v2/pages/Editor/nodes/TaskNode/context/TaskDetails/TaskDetails.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { observer } from "mobx-react-lite";
22
import { useEffect, useRef, useState } from "react";
33

4+
import type { SaveAction } from "@/components/shared/ComponentEditor/saveAction";
45
import { StackingControls } from "@/components/shared/ReactFlow/FlowControls/StackingControls";
56
import { Button } from "@/components/ui/button";
67
import { Icon } from "@/components/ui/icon";
@@ -11,16 +12,20 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
1112
import { Heading, Text } from "@/components/ui/typography";
1213
import useToastNotification from "@/hooks/useToastNotification";
1314
import { useAnalytics } from "@/providers/AnalyticsProvider";
15+
import { useDialog } from "@/providers/DialogProvider/hooks/useDialog";
16+
import { convertCancelErrorTo } from "@/providers/DialogProvider/utils";
1417
import { AnnotationsBlock } from "@/routes/v2/pages/Editor/components/AnnotationsBlock/AnnotationsBlock";
1518
import { useTaskActions } from "@/routes/v2/pages/Editor/store/actions/useTaskActions";
1619
import { useEditorSession } from "@/routes/v2/pages/Editor/store/EditorSessionContext";
1720
import { useSpec } from "@/routes/v2/shared/providers/SpecContext";
1821
import { useSharedStores } from "@/routes/v2/shared/store/SharedStoreContext";
1922
import { SYSTEM_ANNOTATIONS, ZINDEX_ANNOTATION } from "@/utils/annotations";
2023
import type { HydratedComponentReference } from "@/utils/componentSpec";
24+
import { diffComponentIO } from "@/utils/componentSpecDiff";
2125
import { tracking } from "@/utils/tracking";
2226

2327
import { getTaskYamlText } from "./components/actions/getTaskYamlText";
28+
import { ChooseSaveActionDialog } from "./components/ChooseSaveActionDialog";
2429
import { ComponentRefBar } from "./components/ComponentRefBar";
2530
import { ConfigurationSection } from "./components/ConfigurationSection";
2631
import { TaskActionsBar } from "./components/TaskActionsBar";
@@ -40,6 +45,7 @@ export const TaskDetails = observer(function TaskDetails({
4045
const { editor } = useSharedStores();
4146
const { undo } = useEditorSession();
4247
const { renameTask, replaceTask } = useTaskActions();
48+
const { open: openDialog } = useDialog();
4349
const notify = useToastNotification();
4450
const spec = useSpec();
4551
const task = useTask(entityId);
@@ -75,9 +81,30 @@ export const TaskDetails = observer(function TaskDetails({
7581

7682
const isSubgraphTask = task.subgraphSpec !== undefined;
7783

84+
const resolveSaveAction = (
85+
hydratedComponent: HydratedComponentReference,
86+
): Promise<SaveAction> => {
87+
const { inputDiff, outputDiff } = diffComponentIO<
88+
{ name: string; type?: unknown },
89+
{ name: string; type?: unknown }
90+
>(task.resolvedComponentSpec, hydratedComponent.spec);
91+
92+
return openDialog({
93+
component: ChooseSaveActionDialog,
94+
props: { taskName: task.name, inputDiff, outputDiff },
95+
size: "md",
96+
}).catch(convertCancelErrorTo<SaveAction>("cancel"));
97+
};
98+
7899
const handleComponentSaved = (
79100
hydratedComponent: HydratedComponentReference,
101+
action: SaveAction,
80102
) => {
103+
if (action !== "update") {
104+
// "place" arrives once placement ships; nothing else applies in place.
105+
return;
106+
}
107+
81108
const result = replaceTask(spec, task.$id, hydratedComponent);
82109
const lostInputs = result.inputDiff?.lostEntities ?? [];
83110

@@ -182,6 +209,7 @@ export const TaskDetails = observer(function TaskDetails({
182209
taskName={task.name}
183210
pythonCode={pythonCode}
184211
onComponentSaved={handleComponentSaved}
212+
resolveSaveAction={resolveSaveAction}
185213
/>
186214
</BlockStack>
187215

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { DiffSection } from "@/components/shared/ComponentDiff/DiffSection";
2+
import type { SaveAction } from "@/components/shared/ComponentEditor/saveAction";
3+
import { Button } from "@/components/ui/button";
4+
import {
5+
DialogDescription,
6+
DialogFooter,
7+
DialogHeader,
8+
DialogTitle,
9+
} from "@/components/ui/dialog";
10+
import { Icon, type IconName } from "@/components/ui/icon";
11+
import { BlockStack } from "@/components/ui/layout";
12+
import { Text } from "@/components/ui/typography";
13+
import type { DialogProps } from "@/providers/DialogProvider/types";
14+
import { type EntityDiff, hasIODiff } from "@/utils/componentSpecDiff";
15+
import { tracking } from "@/utils/tracking";
16+
17+
interface ChooseSaveActionDialogExtraProps {
18+
taskName: string;
19+
inputDiff: EntityDiff<{ name: string }>;
20+
outputDiff: EntityDiff<{ name: string }>;
21+
/** Whether to offer "Place as a new task" (enabled once placement ships). */
22+
allowPlace?: boolean;
23+
}
24+
25+
export function ChooseSaveActionDialog({
26+
close,
27+
cancel,
28+
taskName,
29+
inputDiff,
30+
outputDiff,
31+
allowPlace = false,
32+
}: DialogProps<SaveAction> & ChooseSaveActionDialogExtraProps) {
33+
const showDiff = hasIODiff(inputDiff, outputDiff);
34+
35+
return (
36+
<>
37+
<DialogHeader>
38+
<DialogTitle>Apply your edit</DialogTitle>
39+
<DialogDescription>
40+
Choose what to do with your changes to “{taskName}”.
41+
</DialogDescription>
42+
</DialogHeader>
43+
44+
{showDiff && (
45+
<BlockStack gap="2">
46+
<DiffSection label="Input" diff={inputDiff} />
47+
<DiffSection label="Output" diff={outputDiff} />
48+
</BlockStack>
49+
)}
50+
51+
<BlockStack gap="2">
52+
<ActionRow
53+
icon="RefreshCw"
54+
title="Update this task"
55+
description="Apply the edit to this task"
56+
onClick={() => close("update")}
57+
autoFocus
58+
{...tracking("v2.pipeline_editor.task_details.save_action.update")}
59+
/>
60+
<ActionRow
61+
icon="Download"
62+
title="Import to library"
63+
description="Save as a new reusable component"
64+
onClick={() => close("import")}
65+
{...tracking("v2.pipeline_editor.task_details.save_action.import")}
66+
/>
67+
{allowPlace && (
68+
<ActionRow
69+
icon="Plus"
70+
title="Place as a new task"
71+
description="Keep this task; add a new one nearby"
72+
onClick={() => close("place")}
73+
{...tracking("v2.pipeline_editor.task_details.save_action.place")}
74+
/>
75+
)}
76+
</BlockStack>
77+
78+
<DialogFooter>
79+
<Button
80+
variant="ghost"
81+
onClick={cancel}
82+
{...tracking("v2.pipeline_editor.task_details.save_action.cancel")}
83+
>
84+
Cancel
85+
</Button>
86+
</DialogFooter>
87+
</>
88+
);
89+
}
90+
91+
function ActionRow({
92+
icon,
93+
title,
94+
description,
95+
onClick,
96+
autoFocus,
97+
...rest
98+
}: {
99+
icon: IconName;
100+
title: string;
101+
description: string;
102+
onClick: () => void;
103+
autoFocus?: boolean;
104+
}) {
105+
return (
106+
<Button
107+
variant="outline"
108+
className="h-auto w-full justify-start gap-3 p-3"
109+
onClick={onClick}
110+
autoFocus={autoFocus}
111+
{...rest}
112+
>
113+
<Icon name={icon} size="sm" className="shrink-0 text-muted-foreground" />
114+
<span className="flex flex-col items-start gap-0.5 text-left">
115+
<Text size="sm" weight="semibold">
116+
{title}
117+
</Text>
118+
<Text size="xs" tone="subdued">
119+
{description}
120+
</Text>
121+
</span>
122+
</Button>
123+
);
124+
}

src/routes/v2/pages/Editor/nodes/TaskNode/context/TaskDetails/components/ComponentRefBar.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useState } from "react";
22

33
import { CodeViewer } from "@/components/shared/CodeViewer";
44
import { ComponentEditorDialog } from "@/components/shared/ComponentEditor/ComponentEditorDialog";
5+
import type { SaveAction } from "@/components/shared/ComponentEditor/saveAction";
56
import ComponentDetailsDialog from "@/components/shared/Dialogs/ComponentDetailsDialog";
67
import { TrimmedDigest } from "@/components/shared/ManageComponent/TrimmedDigest";
78
import { Button } from "@/components/ui/button";
@@ -38,7 +39,11 @@ interface ComponentRefBarProps {
3839
pythonCode: string | undefined;
3940
onComponentSaved?: (
4041
hydratedComponent: HydratedComponentReference,
42+
action: SaveAction,
4143
) => void | Promise<void>;
44+
resolveSaveAction?: (
45+
hydratedComponent: HydratedComponentReference,
46+
) => Promise<SaveAction>;
4247
}
4348

4449
export function ComponentRefBar({
@@ -47,6 +52,7 @@ export function ComponentRefBar({
4752
taskName,
4853
pythonCode,
4954
onComponentSaved,
55+
resolveSaveAction,
5056
}: ComponentRefBarProps) {
5157
const { track } = useAnalytics();
5258
const notify = useToastNotification();
@@ -201,6 +207,7 @@ export function ComponentRefBar({
201207
text={yamlText}
202208
onClose={() => setIsEditDialogOpen(false)}
203209
onComponentSaved={onComponentSaved}
210+
resolveSaveAction={resolveSaveAction}
204211
/>
205212
)}
206213

0 commit comments

Comments
 (0)