Skip to content

Commit f5b8ed7

Browse files
committed
Rework Action Buttons into new Action Framework
1 parent 1139464 commit f5b8ed7

File tree

2 files changed

+229
-67
lines changed

2 files changed

+229
-67
lines changed

src/components/PipelineRun/RunDetails.tsx

Lines changed: 161 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,34 @@
1+
import { useMutation } from "@tanstack/react-query";
2+
import { useNavigate } from "@tanstack/react-router";
3+
14
import { CopyText } from "@/components/shared/CopyText/CopyText";
25
import { BlockStack, InlineStack } from "@/components/ui/layout";
36
import { Spinner } from "@/components/ui/spinner";
47
import { Paragraph, Text } from "@/components/ui/typography";
58
import { useCheckComponentSpecFromPath } from "@/hooks/useCheckComponentSpecFromPath";
9+
import useToastNotification from "@/hooks/useToastNotification";
610
import { useUserDetails } from "@/hooks/useUserDetails";
711
import { useBackend } from "@/providers/BackendProvider";
812
import { useComponentSpec } from "@/providers/ComponentSpecProvider";
913
import { useExecutionData } from "@/providers/ExecutionDataProvider";
14+
import { APP_ROUTES } from "@/routes/router";
1015
import {
1116
countTaskStatuses,
1217
getRunStatus,
1318
isStatusComplete,
1419
isStatusInProgress,
1520
} from "@/services/executionService";
21+
import {
22+
cancelPipelineRun,
23+
copyRunToPipeline,
24+
} from "@/services/pipelineRunService";
25+
import type { PipelineRun } from "@/types/pipelineRun";
26+
import { getInitialName } from "@/utils/getComponentName";
27+
import { submitPipelineRun } from "@/utils/submitPipeline";
28+
29+
import { isAuthorizationRequired } from "../shared/Authentication/helpers";
30+
import { useAuthLocalStorage } from "../shared/Authentication/useAuthLocalStorage";
31+
import { useAwaitAuthorization } from "../shared/Authentication/useAwaitAuthorization";
1632
import {
1733
ActionBlock,
1834
type ActionOrReactNode,
@@ -23,18 +39,20 @@ import { TextBlock } from "../shared/ContextPanel/Blocks/TextBlock";
2339
import PipelineIO from "../shared/Execution/PipelineIO";
2440
import { InfoBox } from "../shared/InfoBox";
2541
import { StatusBar, StatusText } from "../shared/Status";
26-
import { CancelPipelineRunButton } from "./components/CancelPipelineRunButton";
27-
import { ClonePipelineButton } from "./components/ClonePipelineButton";
28-
import { InspectPipelineButton } from "./components/InspectPipelineButton";
29-
import { RerunPipelineButton } from "./components/RerunPipelineButton";
3042
import { useState } from "react";
31-
import { componentSpecToText } from "@/utils/yaml";
3243
import { CodeViewer } from "../shared/CodeViewer";
33-
import TooltipButton from "../shared/Buttons/TooltipButton";
34-
import { Icon } from "../ui/icon";
44+
import { componentSpecToText } from "@/utils/yaml";
3545

3646
export const RunDetails = () => {
37-
const { configured } = useBackend();
47+
const navigate = useNavigate();
48+
const notify = useToastNotification();
49+
50+
const { available, configured, backendUrl } = useBackend();
51+
52+
const { awaitAuthorization, isAuthorized } = useAwaitAuthorization();
53+
const { getToken } = useAuthLocalStorage();
54+
const { data: currentUserDetails } = useUserDetails();
55+
3856
const { componentSpec } = useComponentSpec();
3957
const {
4058
rootDetails: details,
@@ -44,7 +62,74 @@ export const RunDetails = () => {
4462
isLoading,
4563
error,
4664
} = useExecutionData();
47-
const { data: currentUserDetails } = useUserDetails();
65+
66+
const { isPending: isPendingClone, mutate: clonePipeline } = useMutation({
67+
mutationFn: async () => {
68+
const name = getInitialName(componentSpec);
69+
return copyRunToPipeline(componentSpec, name);
70+
},
71+
onSuccess: (result) => {
72+
if (result?.url) {
73+
notify(`Pipeline "${result.name}" cloned`, "success");
74+
navigate({ to: result.url });
75+
}
76+
},
77+
onError: (error) => {
78+
notify(`Error cloning pipeline: ${error}`, "error");
79+
},
80+
});
81+
82+
const {
83+
mutate: cancelPipeline,
84+
isPending: isPendingCancel,
85+
isSuccess: isSuccessCancel,
86+
} = useMutation({
87+
mutationFn: (runId: string) => cancelPipelineRun(runId, backendUrl),
88+
onSuccess: () => {
89+
notify(`Pipeline run ${runId} cancelled`, "success");
90+
},
91+
onError: (error) => {
92+
notify(`Error cancelling run: ${error}`, "error");
93+
},
94+
});
95+
96+
const onSuccess = (response: PipelineRun) => {
97+
navigate({ to: `${APP_ROUTES.RUNS}/${response.id}` });
98+
};
99+
100+
const onError = (error: Error | string) => {
101+
const message = `Failed to submit pipeline. ${error instanceof Error ? error.message : String(error)}`;
102+
notify(message, "error");
103+
};
104+
105+
const getAuthToken = async (): Promise<string | undefined> => {
106+
const authorizationRequired = isAuthorizationRequired();
107+
108+
if (authorizationRequired && !isAuthorized) {
109+
const token = await awaitAuthorization();
110+
if (token) {
111+
return token;
112+
}
113+
}
114+
115+
return getToken();
116+
};
117+
118+
const { mutate: rerunPipeline, isPending: isPendingRerun } = useMutation({
119+
mutationFn: async () => {
120+
const authorizationToken = await getAuthToken();
121+
122+
return new Promise<PipelineRun>((resolve, reject) => {
123+
submitPipelineRun(componentSpec, backendUrl, {
124+
authorizationToken,
125+
onSuccess: resolve,
126+
onError: reject,
127+
});
128+
});
129+
},
130+
onSuccess,
131+
onError,
132+
});
48133

49134
const [isYamlFullscreen, setIsYamlFullscreen] = useState(false);
50135

@@ -60,6 +145,36 @@ export const RunDetails = () => {
60145
const isRunCreator =
61146
currentUserDetails?.id && metadata?.created_by === currentUserDetails.id;
62147

148+
const handleInspect = () => {
149+
navigate({ to: editorRoute });
150+
};
151+
152+
const handleClone = () => {
153+
clonePipeline();
154+
};
155+
156+
const handleCancel = () => {
157+
if (!runId) {
158+
notify(`Failed to cancel run. No run ID found.`, "warning");
159+
return;
160+
}
161+
162+
if (!available) {
163+
notify(`Backend is not available. Cannot cancel run.`, "warning");
164+
return;
165+
}
166+
167+
try {
168+
cancelPipeline(runId);
169+
} catch (error) {
170+
notify(`Error cancelling run: ${error}`, "error");
171+
}
172+
};
173+
174+
const handleRerun = () => {
175+
rerunPipeline();
176+
};
177+
63178
if (error || !details || !state || !componentSpec) {
64179
return (
65180
<BlockStack align="center" inlineAlign="center" className="h-full">
@@ -97,37 +212,43 @@ export const RunDetails = () => {
97212

98213
const annotations = componentSpec.metadata?.annotations || {};
99214

100-
const actions: ActionOrReactNode[] = [];
101-
102-
actions.push(
103-
<TooltipButton
104-
variant="outline"
105-
tooltip="View YAML"
106-
onClick={() => setIsYamlFullscreen(true)}
107-
>
108-
<Icon name="FileCodeCorner" />
109-
</TooltipButton>,
110-
);
111-
112-
if (canAccessEditorSpec && componentSpec.name) {
113-
actions.push(
114-
<InspectPipelineButton key="inspect" pipelineName={componentSpec.name} />,
115-
);
116-
}
117-
118-
actions.push(
119-
<ClonePipelineButton key="clone" componentSpec={componentSpec} />,
120-
);
121-
122-
if (isInProgress && isRunCreator) {
123-
actions.push(<CancelPipelineRunButton key="cancel" runId={runId} />);
124-
}
125-
126-
if (isComplete) {
127-
actions.push(
128-
<RerunPipelineButton key="rerun" componentSpec={componentSpec} />,
129-
);
130-
}
215+
const actions: ActionOrReactNode[] = [
216+
{
217+
label: "View YAML",
218+
icon: "FileCodeCorner",
219+
onClick: () => setIsYamlFullscreen(true),
220+
},
221+
{
222+
label: "Inspect Pipeline",
223+
icon: "SquareMousePointer",
224+
hidden: !canAccessEditorSpec,
225+
onClick: handleInspect,
226+
},
227+
{
228+
label: "Clone Pipeline",
229+
icon: "CopyPlus",
230+
disabled: isPendingClone,
231+
onClick: handleClone,
232+
},
233+
{
234+
label: "Cancel Run",
235+
confirmation:
236+
"The run will be scheduled for cancellation. This action cannot be undone.",
237+
icon: isSuccessCancel ? "CircleSlash" : "CircleX",
238+
className: isSuccessCancel ? "bg-primary text-primary-foreground" : "",
239+
destructive: !isSuccessCancel,
240+
hidden: !isInProgress || !isRunCreator,
241+
disabled: isPendingCancel || isSuccessCancel,
242+
onClick: handleCancel,
243+
},
244+
{
245+
label: "Rerun Pipeline",
246+
icon: "RefreshCcw",
247+
disabled: isPendingRerun,
248+
hidden: !isComplete,
249+
onClick: handleRerun,
250+
},
251+
];
131252

132253
return (
133254
<>
Lines changed: 68 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
1-
import { type ReactNode } from "react";
1+
import { useState, type ReactNode } from "react";
22

33
import { Icon, type IconName } from "@/components/ui/icon";
44
import { InlineStack } from "@/components/ui/layout";
55

66
import TooltipButton from "../../Buttons/TooltipButton";
7+
import { ConfirmationDialog } from "../../Dialogs";
78

89
export type Action = {
910
label: string;
1011
destructive?: boolean;
12+
disabled?: boolean;
1113
hidden?: boolean;
14+
confirmation?: string;
1215
onClick: () => void;
16+
className?: string;
1317
} & (
1418
| { icon: IconName; content?: never }
1519
| { content: ReactNode; icon?: never }
@@ -24,32 +28,69 @@ interface ActionBlockProps {
2428
}
2529

2630
export const ActionBlock = ({ actions, className }: ActionBlockProps) => {
31+
const [isOpen, setIsOpen] = useState(false);
32+
const [dialogAction, setDialogAction] = useState<Action | null>(null);
33+
34+
const openConfirmationDialog = (action: Action) => {
35+
return () => {
36+
setDialogAction(action);
37+
setIsOpen(true);
38+
};
39+
};
40+
41+
const handleConfirm = () => {
42+
setIsOpen(false);
43+
dialogAction?.onClick();
44+
setDialogAction(null);
45+
};
46+
47+
const handleCancel = () => {
48+
setIsOpen(false);
49+
setDialogAction(null);
50+
};
51+
2752
return (
28-
<InlineStack gap="2" className={className}>
29-
{actions.map((action, index) => {
30-
if (!action || typeof action !== "object" || !("label" in action)) {
31-
return <div key={index}>{action}</div>;
32-
}
33-
34-
if (action.hidden) {
35-
return null;
36-
}
37-
38-
return (
39-
<TooltipButton
40-
key={action.label}
41-
variant={action.destructive ? "destructive" : "outline"}
42-
tooltip={action.label}
43-
onClick={action.onClick}
44-
>
45-
{action.content === undefined && action.icon ? (
46-
<Icon name={action.icon} />
47-
) : (
48-
action.content
49-
)}
50-
</TooltipButton>
51-
);
52-
})}
53-
</InlineStack>
53+
<>
54+
<InlineStack gap="2" className={className}>
55+
{actions.map((action, index) => {
56+
if (!action || typeof action !== "object" || !("label" in action)) {
57+
return <div key={index}>{action}</div>;
58+
}
59+
60+
if (action.hidden) {
61+
return null;
62+
}
63+
64+
return (
65+
<TooltipButton
66+
key={action.label}
67+
variant={action.destructive ? "destructive" : "outline"}
68+
tooltip={action.label}
69+
onClick={
70+
!!action.confirmation
71+
? openConfirmationDialog(action)
72+
: action.onClick
73+
}
74+
disabled={action.disabled}
75+
className={action.className}
76+
>
77+
{action.content === undefined && action.icon ? (
78+
<Icon name={action.icon} />
79+
) : (
80+
action.content
81+
)}
82+
</TooltipButton>
83+
);
84+
})}
85+
</InlineStack>
86+
87+
<ConfirmationDialog
88+
isOpen={isOpen}
89+
title={dialogAction?.label}
90+
description={dialogAction?.confirmation}
91+
onConfirm={handleConfirm}
92+
onCancel={handleCancel}
93+
/>
94+
</>
5495
);
5596
};

0 commit comments

Comments
 (0)