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! 🚧🚧"; +}