Skip to content

Commit 4885e24

Browse files
committed
feat: clone pipeline from run with arguments
1 parent 22badcd commit 4885e24

File tree

7 files changed

+201
-36
lines changed

7 files changed

+201
-36
lines changed

src/components/PipelineRun/RunDetails.test.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,19 @@ describe("<RunDetails/>", () => {
213213
expect(cloneButton).toBeInTheDocument();
214214
});
215215
});
216+
217+
test("should render clone button with arguments", async () => {
218+
// act
219+
renderWithProviders(<RunDetails />);
220+
221+
// assert
222+
await waitFor(() => {
223+
const cloneButton = screen.getByTestId(
224+
"clone-pipeline-run-with-arguments-button",
225+
);
226+
expect(cloneButton).toBeInTheDocument();
227+
});
228+
});
216229
});
217230

218231
describe("Cancel Pipeline Run Button", () => {

src/components/PipelineRun/RunDetails.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,10 @@ import {
2727
} from "@/utils/executionStatus";
2828

2929
import { CancelPipelineRunButton } from "./components/CancelPipelineRunButton";
30-
import { ClonePipelineButton } from "./components/ClonePipelineButton";
30+
import {
31+
ClonePipelineButton,
32+
ClonePipelineButtonWithArguments,
33+
} from "./components/ClonePipelineButton";
3134
import { InspectPipelineButton } from "./components/InspectPipelineButton";
3235
import { RerunPipelineButton } from "./components/RerunPipelineButton";
3336

@@ -117,6 +120,14 @@ export const RunDetails = () => {
117120
/>,
118121
);
119122

123+
actions.push(
124+
<ClonePipelineButtonWithArguments
125+
key="clone-with-arguments"
126+
componentSpec={componentSpec}
127+
runId={runId}
128+
/>,
129+
);
130+
120131
if (isInProgress && isRunCreator) {
121132
actions.push(<CancelPipelineRunButton key="cancel" runId={runId} />);
122133
}

src/components/PipelineRun/components/ClonePipelineButton.test.tsx

Lines changed: 78 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,13 @@ import { act, fireEvent, render } from "@testing-library/react";
44
import { beforeEach, describe, expect, test, vi } from "vitest";
55

66
import useToastNotification from "@/hooks/useToastNotification";
7+
import * as ExecutionDataProvider from "@/providers/ExecutionDataProvider";
78
import * as pipelineRunService from "@/services/pipelineRunService";
89

9-
import { ClonePipelineButton } from "./ClonePipelineButton";
10+
import {
11+
ClonePipelineButton,
12+
ClonePipelineButtonWithArguments,
13+
} from "./ClonePipelineButton";
1014

1115
vi.mock("@tanstack/react-router", async (importOriginal) => ({
1216
...(await importOriginal()),
@@ -21,6 +25,7 @@ describe("<ClonePipelineButton/>", () => {
2125

2226
beforeEach(() => {
2327
vi.clearAllMocks();
28+
vi.restoreAllMocks();
2429
vi.mocked(useToastNotification).mockReturnValue(mockNotify);
2530
vi.mocked(pipelineRunService.copyRunToPipeline).mockResolvedValue({
2631
url: "/editor/cloned-pipeline",
@@ -37,25 +42,81 @@ describe("<ClonePipelineButton/>", () => {
3742
</QueryClientProvider>,
3843
);
3944

40-
test("renders clone button", () => {
41-
renderWithClient(<ClonePipelineButton componentSpec={componentSpec} />);
42-
expect(
43-
screen.queryByTestId("clone-pipeline-run-button"),
44-
).toBeInTheDocument();
45-
});
45+
describe("Clone Pipeline Button", () => {
46+
test("renders clone button", () => {
47+
renderWithClient(<ClonePipelineButton componentSpec={componentSpec} />);
48+
expect(
49+
screen.queryByTestId("clone-pipeline-run-button"),
50+
).toBeInTheDocument();
51+
});
4652

47-
test("calls copyRunToPipeline and navigate on click", async () => {
48-
renderWithClient(<ClonePipelineButton componentSpec={componentSpec} />);
49-
const cloneButton = screen.getByTestId("clone-pipeline-run-button");
50-
act(() => fireEvent.click(cloneButton));
53+
test("calls copyRunToPipeline and navigate on click", async () => {
54+
renderWithClient(<ClonePipelineButton componentSpec={componentSpec} />);
55+
const cloneButton = screen.getByTestId("clone-pipeline-run-button");
56+
act(() => fireEvent.click(cloneButton));
5157

52-
await waitFor(() => {
53-
expect(pipelineRunService.copyRunToPipeline).toHaveBeenCalled();
58+
await waitFor(() => {
59+
expect(pipelineRunService.copyRunToPipeline).toHaveBeenCalled();
60+
});
61+
62+
expect(mockNotify).toHaveBeenCalledWith(
63+
expect.stringContaining("cloned"),
64+
"success",
65+
);
5466
});
67+
});
5568

56-
expect(mockNotify).toHaveBeenCalledWith(
57-
expect.stringContaining("cloned"),
58-
"success",
59-
);
69+
describe("Clone Pipeline Button With Arguments", () => {
70+
test("renders clone button with arguments", () => {
71+
renderWithClient(
72+
<ClonePipelineButtonWithArguments componentSpec={componentSpec} />,
73+
);
74+
expect(
75+
screen.queryByTestId("clone-pipeline-run-with-arguments-button"),
76+
).toBeInTheDocument();
77+
});
78+
79+
test("calls copyRunToPipeline and navigate on click with arguments", async () => {
80+
const mockTaskArguments = {
81+
input_param: "input_value",
82+
another_param: "another_value",
83+
};
84+
85+
vi.spyOn(
86+
ExecutionDataProvider,
87+
"useExecutionDataOptional",
88+
).mockReturnValue({
89+
rootDetails: {
90+
task_spec: {
91+
arguments: mockTaskArguments,
92+
},
93+
},
94+
} as unknown as ReturnType<
95+
typeof ExecutionDataProvider.useExecutionDataOptional
96+
>);
97+
98+
renderWithClient(
99+
<ClonePipelineButtonWithArguments componentSpec={componentSpec} />,
100+
);
101+
102+
const cloneButton = screen.getByTestId(
103+
"clone-pipeline-run-with-arguments-button",
104+
);
105+
act(() => fireEvent.click(cloneButton));
106+
107+
await waitFor(() => {
108+
expect(pipelineRunService.copyRunToPipeline).toHaveBeenCalledWith(
109+
componentSpec,
110+
undefined,
111+
expect.stringContaining("Test Pipeline"),
112+
mockTaskArguments,
113+
);
114+
});
115+
116+
expect(mockNotify).toHaveBeenCalledWith(
117+
expect.stringContaining("cloned"),
118+
"success",
119+
);
120+
});
60121
});
61122
});
Lines changed: 70 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,50 @@
11
import { useMutation } from "@tanstack/react-query";
22
import { useNavigate } from "@tanstack/react-router";
3-
import { CopyPlus } from "lucide-react";
4-
import { useCallback } from "react";
3+
import {
4+
type ComponentProps,
5+
type PropsWithChildren,
6+
useCallback,
7+
} from "react";
58

69
import TooltipButton from "@/components/shared/Buttons/TooltipButton";
10+
import { Icon } from "@/components/ui/icon";
711
import useToastNotification from "@/hooks/useToastNotification";
12+
import { useExecutionDataOptional } from "@/providers/ExecutionDataProvider";
813
import { copyRunToPipeline } from "@/services/pipelineRunService";
914
import type { ComponentSpec } from "@/utils/componentSpec";
1015
import { getInitialName } from "@/utils/getComponentName";
16+
import { extractTaskArguments } from "@/utils/nodes/taskArguments";
1117

1218
type ClonePipelineButtonProps = {
1319
componentSpec: ComponentSpec;
1420
runId?: string | null;
21+
tooltip?: string;
22+
withArguments?: boolean;
1523
};
1624

17-
export const ClonePipelineButton = ({
25+
const BaseClonePipelineButton = ({
1826
componentSpec,
1927
runId,
20-
}: ClonePipelineButtonProps) => {
28+
tooltip,
29+
children,
30+
withArguments = false,
31+
...tooltipButtonProps
32+
}: PropsWithChildren<
33+
ClonePipelineButtonProps & ComponentProps<typeof TooltipButton>
34+
>) => {
2135
const navigate = useNavigate();
2236
const notify = useToastNotification();
37+
const runDetails = useExecutionDataOptional();
2338

2439
const { isPending, mutate: clonePipeline } = useMutation({
25-
mutationFn: async () => {
40+
mutationFn: async ({
41+
taskArguments,
42+
}: {
43+
taskArguments?: Record<string, string>;
44+
}) => {
2645
const name = getInitialName(componentSpec);
27-
return copyRunToPipeline(componentSpec, runId, name);
46+
47+
return copyRunToPipeline(componentSpec, runId, name, taskArguments);
2848
},
2949
onSuccess: (result) => {
3050
if (result?.url) {
@@ -36,19 +56,58 @@ export const ClonePipelineButton = ({
3656
notify(`Error cloning pipeline: ${error}`, "error");
3757
},
3858
});
59+
3960
const handleClone = useCallback(() => {
40-
clonePipeline();
41-
}, [clonePipeline]);
61+
const taskArguments = withArguments
62+
? extractTaskArguments(runDetails?.rootDetails?.task_spec.arguments)
63+
: undefined;
64+
65+
clonePipeline({ taskArguments });
66+
}, [clonePipeline, withArguments, runDetails]);
4267

4368
return (
4469
<TooltipButton
4570
variant="outline"
4671
onClick={handleClone}
47-
tooltip="Clone pipeline"
72+
tooltip={tooltip}
4873
disabled={isPending}
49-
data-testid="clone-pipeline-run-button"
74+
{...tooltipButtonProps}
5075
>
51-
<CopyPlus className="w-4 h-4" />
76+
{children}
5277
</TooltipButton>
5378
);
5479
};
80+
81+
export const ClonePipelineButton = ({
82+
componentSpec,
83+
runId,
84+
}: Pick<ClonePipelineButtonProps, "componentSpec" | "runId">) => {
85+
return (
86+
<BaseClonePipelineButton
87+
componentSpec={componentSpec}
88+
runId={runId}
89+
withArguments={false}
90+
tooltip="Clone pipeline"
91+
data-testid="clone-pipeline-run-button"
92+
>
93+
<Icon name="CopyPlus" />
94+
</BaseClonePipelineButton>
95+
);
96+
};
97+
98+
export const ClonePipelineButtonWithArguments = ({
99+
componentSpec,
100+
runId,
101+
}: Pick<ClonePipelineButtonProps, "componentSpec" | "runId">) => {
102+
return (
103+
<BaseClonePipelineButton
104+
componentSpec={componentSpec}
105+
runId={runId}
106+
withArguments={true}
107+
tooltip="Clone pipeline with arguments"
108+
data-testid="clone-pipeline-run-with-arguments-button"
109+
>
110+
<Icon name="CopySlash" />
111+
</BaseClonePipelineButton>
112+
);
113+
};

src/services/pipelineRunService.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ export const copyRunToPipeline = async (
7777
componentSpec: ComponentSpec,
7878
runId?: string | null,
7979
name?: string,
80+
taskArguments?: Record<string, string>,
8081
) => {
8182
if (!componentSpec) {
8283
console.error("No component spec found to copy");
@@ -100,6 +101,15 @@ export const copyRunToPipeline = async (
100101
cleanComponentSpec.metadata.annotations["cloned_from_run_id"] = runId;
101102
}
102103

104+
if (taskArguments) {
105+
// update all values of inputs with the task arguments
106+
cleanComponentSpec.inputs?.forEach((input) => {
107+
if (taskArguments[input.name]) {
108+
input.value = taskArguments[input.name];
109+
}
110+
});
111+
}
112+
103113
// Remove caching strategy from all tasks
104114
if (isGraphImplementation(cleanComponentSpec.implementation)) {
105115
const tasks = cleanComponentSpec.implementation.graph.tasks;

src/utils/nodes/taskArguments.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,19 @@ export function getArgumentValue(
2222

2323
return undefined;
2424
}
25+
26+
/**
27+
* Returns a record of task arguments with the value as a string.
28+
*
29+
* @param taskArguments
30+
* @returns
31+
*/
32+
export function extractTaskArguments(
33+
taskArguments: TaskSpecOutput["arguments"],
34+
): Record<string, string> {
35+
return Object.fromEntries(
36+
Object.entries(taskArguments ?? {})
37+
.map(([key, _]) => [key, getArgumentValue(taskArguments, key)])
38+
.filter((entry) => entry[1] !== undefined) ?? [],
39+
);
40+
}

src/utils/submitPipeline.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
import type { PipelineRun } from "@/types/pipelineRun";
1111

1212
import type { ComponentReference, ComponentSpec } from "./componentSpec";
13-
import { getArgumentValue } from "./nodes/taskArguments";
13+
import { extractTaskArguments } from "./nodes/taskArguments";
1414
import { componentSpecFromYaml } from "./yaml";
1515

1616
export async function submitPipelineRun(
@@ -37,12 +37,7 @@ export async function submitPipelineRun(
3737
);
3838
const argumentsFromInputs = getArgumentsFromInputs(fullyLoadedSpec);
3939
const normalizedTaskArguments = options?.taskArguments
40-
? Object.fromEntries(
41-
Object.entries(options.taskArguments).map(([key, _]) => [
42-
key,
43-
getArgumentValue(options.taskArguments, key),
44-
]),
45-
)
40+
? extractTaskArguments(options.taskArguments)
4641
: {};
4742
const payloadArguments = {
4843
...argumentsFromInputs,

0 commit comments

Comments
 (0)