Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[UI v2] feat: Adds flow run button to deployment details page #17051

Merged
merged 1 commit into from
Feb 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -28,7 +29,7 @@ export const DeploymentDetailsPage = ({ id }: DeploymentDetailsPageProps) => {
<DeploymentFlowLink flowId={data.flow_id} />
</div>
<div className="flex align-middle gap-2">
<div className="border border-red-400">{"<RunButton />"}</div>
<RunFlowButton deployment={data} />
<DeploymentActionMenu
id={id}
onDelete={() => confirmDelete(data, { shouldNavigate: true })}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { RunFlowButton } from "./run-flow-button";
Original file line number Diff line number Diff line change
@@ -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<typeof RunFlowButton>;

export default meta;

export const story: StoryObj = { name: "RunFlowButton" };
Original file line number Diff line number Diff line change
@@ -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: () => (
<>
<Toaster />
<RunFlowButton {...props} />,
</>
),
});

const router = createRouter({
routeTree: rootRoute,
history: createMemoryHistory({
initialEntries: ["/"],
}),
context: { queryClient: new QueryClient() },
});
// @ts-expect-error - Type error from using a test router
return <RouterProvider router={router} />;
};

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(<RunFlowButtonRouter deployment={MOCK_DEPLOYMENT} />, {
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(<RunFlowButtonRouter deployment={MOCK_DEPLOYMENT} />, {
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",
);
});
});
Original file line number Diff line number Diff line change
@@ -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: (
<Link to="/runs/flow-run/$id" params={{ id: res.id }}>
<Button size="sm">View run</Button>
</Link>
),
description: (
<p>
<span className="font-bold">{res.name}</span> scheduled to start{" "}
<span className="font-bold">now</span>
</p>
),
});
},
onError: (error) => {
const message =
error.message || "Unknown error while creating flow run.";
console.error(message);
},
},
);
};

return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button loading={isPending}>
Run <Icon className="ml-1 h-4 w-4" id="Play" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => handleClickQuickRun(deployment.id)}>
Quick run
</DropdownMenuItem>
<Link
to="/deployments/deployment/$id/run"
params={{ id: deployment.id }}
search={{ parameters: deployment.parameters }}
>
<DropdownMenuItem>Custom run</DropdownMenuItem>
</Link>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
};
31 changes: 30 additions & 1 deletion ui-v2/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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__'
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -695,6 +718,7 @@ export interface RootRouteChildren {
WorkPoolsWorkPoolWorkPoolNameRoute: typeof WorkPoolsWorkPoolWorkPoolNameRouteWithChildren
DeploymentsDeploymentIdDuplicateRoute: typeof DeploymentsDeploymentIdDuplicateRoute
DeploymentsDeploymentIdEditRoute: typeof DeploymentsDeploymentIdEditRoute
DeploymentsDeploymentIdRunRoute: typeof DeploymentsDeploymentIdRunRoute
}

const rootRouteChildren: RootRouteChildren = {
Expand Down Expand Up @@ -724,6 +748,7 @@ const rootRouteChildren: RootRouteChildren = {
WorkPoolsWorkPoolWorkPoolNameRouteWithChildren,
DeploymentsDeploymentIdDuplicateRoute: DeploymentsDeploymentIdDuplicateRoute,
DeploymentsDeploymentIdEditRoute: DeploymentsDeploymentIdEditRoute,
DeploymentsDeploymentIdRunRoute: DeploymentsDeploymentIdRunRoute,
}

export const routeTree = rootRoute
Expand Down Expand Up @@ -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"
]
},
"/": {
Expand Down Expand Up @@ -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"
Expand Down
23 changes: 23 additions & 0 deletions ui-v2/src/routes/deployments/deployment_.$id.run.tsx
Original file line number Diff line number Diff line change
@@ -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! 🚧🚧";
}
Loading