From b1908af8fc5f580865654aa10b6820da6421cff2 Mon Sep 17 00:00:00 2001
From: Devin Villarosa <102188207+devinvillarosa@users.noreply.github.com>
Date: Mon, 10 Feb 2025 09:07:59 -0800
Subject: [PATCH] [UI v2] feat: Adds flow run button to deployment details page
(#17051)
---
.../deployments/deployment-details-page.tsx | 3 +-
.../deployments/run-flow-button/index.ts | 1 +
.../run-flow-button.stories.tsx | 31 ++++++
.../run-flow-button/run-flow-button.test.tsx | 95 +++++++++++++++++++
.../run-flow-button/run-flow-button.tsx | 89 +++++++++++++++++
ui-v2/src/routeTree.gen.ts | 31 +++++-
.../deployments/deployment_.$id.run.tsx | 23 +++++
7 files changed, 271 insertions(+), 2 deletions(-)
create mode 100644 ui-v2/src/components/deployments/run-flow-button/index.ts
create mode 100644 ui-v2/src/components/deployments/run-flow-button/run-flow-button.stories.tsx
create mode 100644 ui-v2/src/components/deployments/run-flow-button/run-flow-button.test.tsx
create mode 100644 ui-v2/src/components/deployments/run-flow-button/run-flow-button.tsx
create mode 100644 ui-v2/src/routes/deployments/deployment_.$id.run.tsx
diff --git a/ui-v2/src/components/deployments/deployment-details-page.tsx b/ui-v2/src/components/deployments/deployment-details-page.tsx
index bbb659dd33d5..6ee8d58fe3ee 100644
--- a/ui-v2/src/components/deployments/deployment-details-page.tsx
+++ b/ui-v2/src/components/deployments/deployment-details-page.tsx
@@ -8,6 +8,7 @@ import { DeploymentDetailsTabs } from "./deployment-details-tabs";
import { DeploymentFlowLink } from "./deployment-flow-link";
import { DeploymentMetadata } from "./deployment-metadata";
import { DeploymentTriggers } from "./deployment-triggers";
+import { RunFlowButton } from "./run-flow-button";
import { useDeleteDeploymentConfirmationDialog } from "./use-delete-deployment-confirmation-dialog";
type DeploymentDetailsPageProps = {
@@ -28,7 +29,7 @@ export const DeploymentDetailsPage = ({ id }: DeploymentDetailsPageProps) => {
-
{""}
+
confirmDelete(data, { shouldNavigate: true })}
diff --git a/ui-v2/src/components/deployments/run-flow-button/index.ts b/ui-v2/src/components/deployments/run-flow-button/index.ts
new file mode 100644
index 000000000000..1782e66628aa
--- /dev/null
+++ b/ui-v2/src/components/deployments/run-flow-button/index.ts
@@ -0,0 +1 @@
+export { RunFlowButton } from "./run-flow-button";
diff --git a/ui-v2/src/components/deployments/run-flow-button/run-flow-button.stories.tsx b/ui-v2/src/components/deployments/run-flow-button/run-flow-button.stories.tsx
new file mode 100644
index 000000000000..5c7ac8164974
--- /dev/null
+++ b/ui-v2/src/components/deployments/run-flow-button/run-flow-button.stories.tsx
@@ -0,0 +1,31 @@
+import {
+ reactQueryDecorator,
+ routerDecorator,
+ toastDecorator,
+} from "@/storybook/utils";
+import type { Meta, StoryObj } from "@storybook/react";
+
+import { createFakeAutomation, createFakeDeployment } from "@/mocks";
+import { buildApiUrl } from "@tests/utils/handlers";
+import { http, HttpResponse } from "msw";
+import { RunFlowButton } from "./run-flow-button";
+
+const meta = {
+ title: "Components/Deployments/RunFlowButton",
+ component: RunFlowButton,
+ decorators: [toastDecorator, routerDecorator, reactQueryDecorator],
+ args: { deployment: createFakeDeployment() },
+ parameters: {
+ msw: {
+ handlers: [
+ http.post(buildApiUrl("/deployments/:id/create_flow_run"), () => {
+ return HttpResponse.json(createFakeAutomation());
+ }),
+ ],
+ },
+ },
+} satisfies Meta;
+
+export default meta;
+
+export const story: StoryObj = { name: "RunFlowButton" };
diff --git a/ui-v2/src/components/deployments/run-flow-button/run-flow-button.test.tsx b/ui-v2/src/components/deployments/run-flow-button/run-flow-button.test.tsx
new file mode 100644
index 000000000000..6265dc8cc52e
--- /dev/null
+++ b/ui-v2/src/components/deployments/run-flow-button/run-flow-button.test.tsx
@@ -0,0 +1,95 @@
+import { Toaster } from "@/components/ui/toaster";
+import { createFakeDeployment, createFakeFlowRun } from "@/mocks";
+import { QueryClient } from "@tanstack/react-query";
+import {
+ RouterProvider,
+ createMemoryHistory,
+ createRootRoute,
+ createRouter,
+} from "@tanstack/react-router";
+import { render, screen, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { buildApiUrl, createWrapper, server } from "@tests/utils";
+import { http, HttpResponse } from "msw";
+import { describe, expect, it } from "vitest";
+import { RunFlowButton, type RunFlowButtonProps } from "./run-flow-button";
+
+describe("RunFlowButton", () => {
+ // Wraps component in test with a Tanstack router provider
+ const RunFlowButtonRouter = (props: RunFlowButtonProps) => {
+ const rootRoute = createRootRoute({
+ component: () => (
+ <>
+
+ ,
+ >
+ ),
+ });
+
+ const router = createRouter({
+ routeTree: rootRoute,
+ history: createMemoryHistory({
+ initialEntries: ["/"],
+ }),
+ context: { queryClient: new QueryClient() },
+ });
+ // @ts-expect-error - Type error from using a test router
+ return ;
+ };
+
+ it("calls quick run option", async () => {
+ // ------------ Setup
+ const MOCK_DEPLOYMENT = createFakeDeployment();
+ const MOCK_FLOW_RUN_RESPONSE = createFakeFlowRun();
+ server.use(
+ http.post(buildApiUrl("/deployments/:id/create_flow_run"), () => {
+ return HttpResponse.json(MOCK_FLOW_RUN_RESPONSE);
+ }),
+ );
+ const user = userEvent.setup();
+ render(, {
+ wrapper: createWrapper(),
+ });
+
+ // ------------ Act
+ await user.click(screen.getByRole("button", { name: "Run", hidden: true }));
+ await user.click(screen.getByRole("menuitem", { name: "Quick run" }));
+
+ // ------------ Assert
+ const list = screen.getByRole("list");
+ expect(within(list).getByRole("status")).toBeVisible();
+ expect(
+ screen.getByRole("button", {
+ name: /view run/i,
+ }),
+ ).toBeVisible();
+ });
+
+ it("custom run option is a link with deployment parameters", async () => {
+ // ------------ Setup
+ const MOCK_DEPLOYMENT = createFakeDeployment({
+ id: "0",
+ parameters: {
+ // @ts-expect-error Need to update schema type
+ paramKey: "paramValue",
+ },
+ });
+ const user = userEvent.setup();
+ render(, {
+ wrapper: createWrapper(),
+ });
+
+ // ------------ Act
+
+ await user.click(screen.getByRole("button", { name: "Run" }));
+
+ // ------------ Assert
+ expect(screen.getByRole("menuitem", { name: "Custom run" })).toBeVisible();
+
+ // Validates URL has search parameters with deployment parameters
+ expect(screen.getByRole("link", { name: "Custom run" })).toHaveAttribute(
+ "href",
+ "/deployments/deployment/0/run?parameters=%7B%22paramKey%22%3A%22paramValue%22%7D",
+ );
+ });
+});
diff --git a/ui-v2/src/components/deployments/run-flow-button/run-flow-button.tsx b/ui-v2/src/components/deployments/run-flow-button/run-flow-button.tsx
new file mode 100644
index 000000000000..f8dec78f17e6
--- /dev/null
+++ b/ui-v2/src/components/deployments/run-flow-button/run-flow-button.tsx
@@ -0,0 +1,89 @@
+import { Deployment } from "@/api/deployments";
+import { useDeploymentCreateFlowRun } from "@/api/flow-runs";
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Icon } from "@/components/ui/icons";
+import { useToast } from "@/hooks/use-toast";
+import { Link } from "@tanstack/react-router";
+
+const DEPLOYMENT_QUICK_RUN_PAYLOAD = {
+ state: {
+ type: "SCHEDULED",
+ message: "Run from the Prefect UI with defaults",
+ state_details: {
+ deferred: false,
+ untrackable_result: false,
+ pause_reschedule: false,
+ },
+ },
+} as const;
+
+export type RunFlowButtonProps = {
+ deployment: Deployment;
+};
+
+export const RunFlowButton = ({ deployment }: RunFlowButtonProps) => {
+ const { toast } = useToast();
+ const { createDeploymentFlowRun, isPending } = useDeploymentCreateFlowRun();
+
+ const handleClickQuickRun = (id: string) => {
+ createDeploymentFlowRun(
+ {
+ id,
+ ...DEPLOYMENT_QUICK_RUN_PAYLOAD,
+ },
+ {
+ onSuccess: (res) => {
+ toast({
+ action: (
+
+
+
+ ),
+ description: (
+
+ {res.name} scheduled to start{" "}
+ now
+
+ ),
+ });
+ },
+ onError: (error) => {
+ const message =
+ error.message || "Unknown error while creating flow run.";
+ console.error(message);
+ },
+ },
+ );
+ };
+
+ return (
+
+
+
+
+
+
+ handleClickQuickRun(deployment.id)}>
+ Quick run
+
+
+ Custom run
+
+
+
+
+ );
+};
diff --git a/ui-v2/src/routeTree.gen.ts b/ui-v2/src/routeTree.gen.ts
index 41ad5228e38d..04a33d4ebee1 100644
--- a/ui-v2/src/routeTree.gen.ts
+++ b/ui-v2/src/routeTree.gen.ts
@@ -36,6 +36,7 @@ import { Route as ConcurrencyLimitsConcurrencyLimitIdImport } from './routes/con
import { Route as BlocksBlockIdImport } from './routes/blocks/block.$id'
import { Route as AutomationsAutomationIdImport } from './routes/automations/automation.$id'
import { Route as ArtifactsKeyKeyImport } from './routes/artifacts/key.$key'
+import { Route as DeploymentsDeploymentIdRunImport } from './routes/deployments/deployment_.$id.run'
import { Route as DeploymentsDeploymentIdEditImport } from './routes/deployments/deployment_.$id.edit'
import { Route as DeploymentsDeploymentIdDuplicateImport } from './routes/deployments/deployment_.$id.duplicate'
import { Route as AutomationsAutomationIdEditImport } from './routes/automations/automation.$id.edit'
@@ -194,6 +195,15 @@ const ArtifactsKeyKeyRoute = ArtifactsKeyKeyImport.update({
path: '/artifacts/key/$key',
getParentRoute: () => rootRoute,
} as any)
+
+const DeploymentsDeploymentIdRunRoute = DeploymentsDeploymentIdRunImport.update(
+ {
+ id: '/deployments/deployment_/$id/run',
+ path: '/deployments/deployment/$id/run',
+ getParentRoute: () => rootRoute,
+ } as any,
+)
+
const DeploymentsDeploymentIdEditRoute =
DeploymentsDeploymentIdEditImport.update({
id: '/deployments/deployment_/$id/edit',
@@ -422,6 +432,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DeploymentsDeploymentIdEditImport
parentRoute: typeof rootRoute
}
+ '/deployments/deployment_/$id/run': {
+ id: '/deployments/deployment_/$id/run'
+ path: '/deployments/deployment/$id/run'
+ fullPath: '/deployments/deployment/$id/run'
+ preLoaderRoute: typeof DeploymentsDeploymentIdRunImport
+ parentRoute: typeof rootRoute
+ }
'/work-pools/work-pool/$workPoolName/queue/$workQueueName': {
id: '/work-pools/work-pool/$workPoolName/queue/$workQueueName'
path: '/queue/$workQueueName'
@@ -507,6 +524,7 @@ export interface FileRoutesByFullPath {
'/automations/automation/$id/edit': typeof AutomationsAutomationIdEditRoute
'/deployments/deployment/$id/duplicate': typeof DeploymentsDeploymentIdDuplicateRoute
'/deployments/deployment/$id/edit': typeof DeploymentsDeploymentIdEditRoute
+ '/deployments/deployment/$id/run': typeof DeploymentsDeploymentIdRunRoute
'/work-pools/work-pool/$workPoolName/queue/$workQueueName': typeof WorkPoolsWorkPoolWorkPoolNameQueueWorkQueueNameRoute
}
@@ -538,6 +556,7 @@ export interface FileRoutesByTo {
'/automations/automation/$id/edit': typeof AutomationsAutomationIdEditRoute
'/deployments/deployment/$id/duplicate': typeof DeploymentsDeploymentIdDuplicateRoute
'/deployments/deployment/$id/edit': typeof DeploymentsDeploymentIdEditRoute
+ '/deployments/deployment/$id/run': typeof DeploymentsDeploymentIdRunRoute
'/work-pools/work-pool/$workPoolName/queue/$workQueueName': typeof WorkPoolsWorkPoolWorkPoolNameQueueWorkQueueNameRoute
}
@@ -571,6 +590,7 @@ export interface FileRoutesById {
'/automations/automation/$id/edit': typeof AutomationsAutomationIdEditRoute
'/deployments/deployment_/$id/duplicate': typeof DeploymentsDeploymentIdDuplicateRoute
'/deployments/deployment_/$id/edit': typeof DeploymentsDeploymentIdEditRoute
+ '/deployments/deployment_/$id/run': typeof DeploymentsDeploymentIdRunRoute
'/work-pools/work-pool/$workPoolName/queue/$workQueueName': typeof WorkPoolsWorkPoolWorkPoolNameQueueWorkQueueNameRoute
}
@@ -605,6 +625,7 @@ export interface FileRouteTypes {
| '/automations/automation/$id/edit'
| '/deployments/deployment/$id/duplicate'
| '/deployments/deployment/$id/edit'
+ | '/deployments/deployment/$id/run'
| '/work-pools/work-pool/$workPoolName/queue/$workQueueName'
fileRoutesByTo: FileRoutesByTo
to:
@@ -635,6 +656,7 @@ export interface FileRouteTypes {
| '/automations/automation/$id/edit'
| '/deployments/deployment/$id/duplicate'
| '/deployments/deployment/$id/edit'
+ | '/deployments/deployment/$id/run'
| '/work-pools/work-pool/$workPoolName/queue/$workQueueName'
id:
| '__root__'
@@ -666,6 +688,7 @@ export interface FileRouteTypes {
| '/automations/automation/$id/edit'
| '/deployments/deployment_/$id/duplicate'
| '/deployments/deployment_/$id/edit'
+ | '/deployments/deployment_/$id/run'
| '/work-pools/work-pool/$workPoolName/queue/$workQueueName'
fileRoutesById: FileRoutesById
}
@@ -695,6 +718,7 @@ export interface RootRouteChildren {
WorkPoolsWorkPoolWorkPoolNameRoute: typeof WorkPoolsWorkPoolWorkPoolNameRouteWithChildren
DeploymentsDeploymentIdDuplicateRoute: typeof DeploymentsDeploymentIdDuplicateRoute
DeploymentsDeploymentIdEditRoute: typeof DeploymentsDeploymentIdEditRoute
+ DeploymentsDeploymentIdRunRoute: typeof DeploymentsDeploymentIdRunRoute
}
const rootRouteChildren: RootRouteChildren = {
@@ -724,6 +748,7 @@ const rootRouteChildren: RootRouteChildren = {
WorkPoolsWorkPoolWorkPoolNameRouteWithChildren,
DeploymentsDeploymentIdDuplicateRoute: DeploymentsDeploymentIdDuplicateRoute,
DeploymentsDeploymentIdEditRoute: DeploymentsDeploymentIdEditRoute,
+ DeploymentsDeploymentIdRunRoute: DeploymentsDeploymentIdRunRoute,
}
export const routeTree = rootRoute
@@ -759,7 +784,8 @@ export const routeTree = rootRoute
"/runs/task-run/$id",
"/work-pools/work-pool/$workPoolName",
"/deployments/deployment_/$id/duplicate",
- "/deployments/deployment_/$id/edit"
+ "/deployments/deployment_/$id/edit",
+ "/deployments/deployment_/$id/run"
]
},
"/": {
@@ -861,6 +887,9 @@ export const routeTree = rootRoute
"/deployments/deployment_/$id/edit": {
"filePath": "deployments/deployment_.$id.edit.tsx"
},
+ "/deployments/deployment_/$id/run": {
+ "filePath": "deployments/deployment_.$id.run.tsx"
+ },
"/work-pools/work-pool/$workPoolName/queue/$workQueueName": {
"filePath": "work-pools/work-pool.$workPoolName.queue.$workQueueName.tsx",
"parent": "/work-pools/work-pool/$workPoolName"
diff --git a/ui-v2/src/routes/deployments/deployment_.$id.run.tsx b/ui-v2/src/routes/deployments/deployment_.$id.run.tsx
new file mode 100644
index 000000000000..00ddf5313daa
--- /dev/null
+++ b/ui-v2/src/routes/deployments/deployment_.$id.run.tsx
@@ -0,0 +1,23 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { zodValidator } from "@tanstack/zod-adapter";
+import { z } from "zod";
+
+// nb: Revisit search params to determine if we're decoding the parameters correctly. Or if there are stricter typings
+// We'll know stricter types as we write more of the webapp
+
+/**
+ * Schema for validating URL search parameters for the create automation page.
+ * @property actions used designate how to pre-populate the fields
+ */
+const searchParams = z
+ .object({ parameters: z.record(z.unknown()).optional() })
+ .optional();
+
+export const Route = createFileRoute("/deployments/deployment_/$id/run")({
+ validateSearch: zodValidator(searchParams),
+ component: RouteComponent,
+});
+
+function RouteComponent() {
+ return "🚧🚧 Pardon our dust! 🚧🚧";
+}