From 3fc67d3584f21a5bc0716174dd89b3aea349cdc7 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 18 Dec 2025 20:53:52 +0000 Subject: [PATCH 1/2] feat(ui-v2): add WorkPoolEditForm component for editing work pools - Create WorkPoolEditForm component with name, description, concurrency limit, and type fields - Name and type fields are disabled/read-only - Description and concurrency limit fields are editable - Add comprehensive test file with 16 test cases - Add Storybook stories with MSW handlers - Update index.ts exports to include new component and useUpdateWorkPool hook - Update route component to use new form Co-Authored-By: alex.s@prefect.io --- ui-v2/src/api/work-pools/index.ts | 2 + ui-v2/src/components/work-pools/edit/index.ts | 2 + .../edit/work-pool-edit-form.stories.tsx | 185 +++++++++++ .../edit/work-pool-edit-form.test.tsx | 299 ++++++++++++++++++ .../work-pools/edit/work-pool-edit-form.tsx | 146 +++++++++ .../work-pool_.$workPoolName.edit.tsx | 11 +- 6 files changed, 644 insertions(+), 1 deletion(-) create mode 100644 ui-v2/src/components/work-pools/edit/work-pool-edit-form.stories.tsx create mode 100644 ui-v2/src/components/work-pools/edit/work-pool-edit-form.test.tsx create mode 100644 ui-v2/src/components/work-pools/edit/work-pool-edit-form.tsx diff --git a/ui-v2/src/api/work-pools/index.ts b/ui-v2/src/api/work-pools/index.ts index 817ab2d0cea9..6ec39bfa72a8 100644 --- a/ui-v2/src/api/work-pools/index.ts +++ b/ui-v2/src/api/work-pools/index.ts @@ -8,10 +8,12 @@ export { useDeleteWorkPool, usePauseWorkPool, useResumeWorkPool, + useUpdateWorkPool, type WorkPool, type WorkPoolCreate, type WorkPoolStatus, type WorkPoolsCountFilter, type WorkPoolsFilter, + type WorkPoolUpdate, type WorkPoolWorker, } from "./work-pools"; diff --git a/ui-v2/src/components/work-pools/edit/index.ts b/ui-v2/src/components/work-pools/edit/index.ts index 68a45c40cb54..dea158ac22e7 100644 --- a/ui-v2/src/components/work-pools/edit/index.ts +++ b/ui-v2/src/components/work-pools/edit/index.ts @@ -1 +1,3 @@ +export { type WorkPoolEditFormValues, workPoolEditSchema } from "./schema"; +export { WorkPoolEditForm } from "./work-pool-edit-form"; export { WorkPoolEditPageHeader } from "./work-pool-edit-page-header"; diff --git a/ui-v2/src/components/work-pools/edit/work-pool-edit-form.stories.tsx b/ui-v2/src/components/work-pools/edit/work-pool-edit-form.stories.tsx new file mode 100644 index 000000000000..0e2265d3f4ec --- /dev/null +++ b/ui-v2/src/components/work-pools/edit/work-pool-edit-form.stories.tsx @@ -0,0 +1,185 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { HttpResponse, http } from "msw"; +import { createFakeWorkPool } from "@/mocks/create-fake-work-pool"; +import { + reactQueryDecorator, + routerDecorator, + toastDecorator, +} from "@/storybook/utils"; +import { WorkPoolEditForm } from "./work-pool-edit-form"; + +const meta: Meta = { + title: "Components/WorkPools/WorkPoolEditForm", + component: WorkPoolEditForm, + decorators: [reactQueryDecorator, routerDecorator, toastDecorator], + parameters: { + layout: "padded", + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + workPool: createFakeWorkPool({ + name: "my-work-pool", + description: "A work pool for running flow runs", + concurrency_limit: 10, + type: "process", + }), + }, + parameters: { + msw: { + handlers: [ + http.patch("http://localhost:4200/api/work_pools/:name", () => { + return new HttpResponse(null, { status: 204 }); + }), + ], + }, + }, +}; + +export const WithNullDescription: Story = { + args: { + workPool: createFakeWorkPool({ + name: "no-description-pool", + description: null, + concurrency_limit: 5, + type: "docker", + }), + }, + parameters: { + msw: { + handlers: [ + http.patch("http://localhost:4200/api/work_pools/:name", () => { + return new HttpResponse(null, { status: 204 }); + }), + ], + }, + }, +}; + +export const WithNullConcurrencyLimit: Story = { + args: { + workPool: createFakeWorkPool({ + name: "unlimited-pool", + description: "A pool with unlimited concurrency", + concurrency_limit: null, + type: "kubernetes", + }), + }, + parameters: { + msw: { + handlers: [ + http.patch("http://localhost:4200/api/work_pools/:name", () => { + return new HttpResponse(null, { status: 204 }); + }), + ], + }, + }, +}; + +export const WithLongDescription: Story = { + args: { + workPool: createFakeWorkPool({ + name: "detailed-pool", + description: + "This is a very detailed description of the work pool that explains its purpose, configuration, and usage guidelines. It spans multiple lines to demonstrate how the textarea handles longer content. The pool is configured for high-throughput batch processing workloads.", + concurrency_limit: 100, + type: "process", + }), + }, + parameters: { + msw: { + handlers: [ + http.patch("http://localhost:4200/api/work_pools/:name", () => { + return new HttpResponse(null, { status: 204 }); + }), + ], + }, + }, +}; + +export const SuccessResponse: Story = { + args: { + workPool: createFakeWorkPool({ + name: "success-pool", + description: "Test successful update", + concurrency_limit: 10, + type: "process", + }), + }, + parameters: { + msw: { + handlers: [ + http.patch("http://localhost:4200/api/work_pools/:name", () => { + return new HttpResponse(null, { status: 204 }); + }), + ], + }, + }, +}; + +export const ErrorResponse: Story = { + args: { + workPool: createFakeWorkPool({ + name: "error-pool", + description: "Test error handling", + concurrency_limit: 10, + type: "process", + }), + }, + parameters: { + msw: { + handlers: [ + http.patch("http://localhost:4200/api/work_pools/:name", () => { + return HttpResponse.json( + { detail: "Work pool not found" }, + { status: 404 }, + ); + }), + ], + }, + }, +}; + +export const KubernetesWorkPool: Story = { + args: { + workPool: createFakeWorkPool({ + name: "production-k8s-pool", + description: "Kubernetes work pool for production deployments", + concurrency_limit: 50, + type: "kubernetes", + }), + }, + parameters: { + msw: { + handlers: [ + http.patch("http://localhost:4200/api/work_pools/:name", () => { + return new HttpResponse(null, { status: 204 }); + }), + ], + }, + }, +}; + +export const DockerWorkPool: Story = { + args: { + workPool: createFakeWorkPool({ + name: "docker-dev-pool", + description: "Docker work pool for development", + concurrency_limit: 20, + type: "docker", + }), + }, + parameters: { + msw: { + handlers: [ + http.patch("http://localhost:4200/api/work_pools/:name", () => { + return new HttpResponse(null, { status: 204 }); + }), + ], + }, + }, +}; diff --git a/ui-v2/src/components/work-pools/edit/work-pool-edit-form.test.tsx b/ui-v2/src/components/work-pools/edit/work-pool-edit-form.test.tsx new file mode 100644 index 000000000000..e8bd5ea17d2f --- /dev/null +++ b/ui-v2/src/components/work-pools/edit/work-pool-edit-form.test.tsx @@ -0,0 +1,299 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createFakeWorkPool } from "@/mocks/create-fake-work-pool"; +import { WorkPoolEditForm } from "./work-pool-edit-form"; + +const mockNavigate = vi.fn(); +const mockHistoryBack = vi.fn(); + +type UpdateWorkPoolOptions = { + onSuccess?: () => void; + onError?: (error: Error) => void; +}; + +const mockUpdateWorkPool = + vi.fn<(data: unknown, options: UpdateWorkPoolOptions) => void>(); + +vi.mock("@tanstack/react-router", () => ({ + useRouter: () => ({ + navigate: mockNavigate, + history: { + back: mockHistoryBack, + }, + }), +})); + +vi.mock("@/api/work-pools", async () => { + const actual = await vi.importActual("@/api/work-pools"); + return { + ...actual, + useUpdateWorkPool: () => ({ + updateWorkPool: mockUpdateWorkPool, + isPending: false, + }), + }; +}); + +vi.mock("sonner", () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +describe("WorkPoolEditForm", () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + vi.clearAllMocks(); + }); + + const renderWorkPoolEditForm = ( + workPool = createFakeWorkPool({ + name: "test-work-pool", + description: "Test description", + concurrency_limit: 10, + type: "process", + }), + ) => { + return render( + + + , + ); + }; + + it("renders all form fields", () => { + renderWorkPoolEditForm(); + + expect(screen.getByLabelText("Name")).toBeInTheDocument(); + expect(screen.getByLabelText("Description (Optional)")).toBeInTheDocument(); + expect( + screen.getByLabelText("Flow Run Concurrency (Optional)"), + ).toBeInTheDocument(); + expect(screen.getByLabelText("Type")).toBeInTheDocument(); + }); + + it("displays work pool name in disabled field", () => { + renderWorkPoolEditForm(); + + const nameInput = screen.getByLabelText("Name"); + expect(nameInput).toHaveValue("test-work-pool"); + expect(nameInput).toBeDisabled(); + }); + + it("displays work pool type in disabled field", () => { + renderWorkPoolEditForm(); + + const typeInput = screen.getByLabelText("Type"); + expect(typeInput).toHaveValue("process"); + expect(typeInput).toBeDisabled(); + }); + + it("displays description in editable field", () => { + renderWorkPoolEditForm(); + + const descriptionInput = screen.getByLabelText("Description (Optional)"); + expect(descriptionInput).toHaveValue("Test description"); + expect(descriptionInput).not.toBeDisabled(); + }); + + it("displays concurrency limit in editable field", () => { + renderWorkPoolEditForm(); + + const concurrencyInput = screen.getByLabelText( + "Flow Run Concurrency (Optional)", + ); + expect(concurrencyInput).toHaveValue(10); + expect(concurrencyInput).not.toBeDisabled(); + }); + + it("allows editing description", async () => { + const user = userEvent.setup(); + renderWorkPoolEditForm(); + + const descriptionInput = screen.getByLabelText("Description (Optional)"); + await user.clear(descriptionInput); + await user.type(descriptionInput, "New description"); + + expect(descriptionInput).toHaveValue("New description"); + }); + + it("allows editing concurrency limit", async () => { + const user = userEvent.setup(); + renderWorkPoolEditForm(); + + const concurrencyInput = screen.getByLabelText( + "Flow Run Concurrency (Optional)", + ); + await user.clear(concurrencyInput); + await user.type(concurrencyInput, "20"); + + expect(concurrencyInput).toHaveValue(20); + }); + + it("handles empty concurrency limit as null", async () => { + const user = userEvent.setup(); + renderWorkPoolEditForm(); + + const concurrencyInput = screen.getByLabelText( + "Flow Run Concurrency (Optional)", + ); + await user.clear(concurrencyInput); + + const saveButton = screen.getByRole("button", { name: "Save" }); + await user.click(saveButton); + + await waitFor(() => { + expect(mockUpdateWorkPool).toHaveBeenCalled(); + const callArgs = mockUpdateWorkPool.mock.calls[0] as [ + { name: string; workPool: { concurrency_limit: number | null } }, + UpdateWorkPoolOptions, + ]; + expect(callArgs[0].workPool.concurrency_limit).toBeNull(); + }); + }); + + it("calls updateWorkPool on form submission", async () => { + const user = userEvent.setup(); + renderWorkPoolEditForm(); + + const saveButton = screen.getByRole("button", { name: "Save" }); + await user.click(saveButton); + + await waitFor(() => { + expect(mockUpdateWorkPool).toHaveBeenCalledWith( + { + name: "test-work-pool", + workPool: { + description: "Test description", + concurrency_limit: 10, + }, + }, + expect.any(Object), + ); + }); + }); + + it("calls router.history.back on cancel", async () => { + const user = userEvent.setup(); + renderWorkPoolEditForm(); + + const cancelButton = screen.getByRole("button", { name: "Cancel" }); + await user.click(cancelButton); + + expect(mockHistoryBack).toHaveBeenCalled(); + }); + + it("shows success toast on successful update", async () => { + const { toast } = await import("sonner"); + const user = userEvent.setup(); + + mockUpdateWorkPool.mockImplementation((_data, options) => { + options.onSuccess?.(); + }); + + renderWorkPoolEditForm(); + + const saveButton = screen.getByRole("button", { name: "Save" }); + await user.click(saveButton); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith("Work pool updated"); + }); + }); + + it("shows error toast on failed update", async () => { + const { toast } = await import("sonner"); + const user = userEvent.setup(); + + mockUpdateWorkPool.mockImplementation((_data, options) => { + options.onError?.(new Error("Network error")); + }); + + renderWorkPoolEditForm(); + + const saveButton = screen.getByRole("button", { name: "Save" }); + await user.click(saveButton); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith( + "Failed to update work pool: Network error", + ); + }); + }); + + it("navigates to work pool page on successful update", async () => { + const user = userEvent.setup(); + + mockUpdateWorkPool.mockImplementation((_data, options) => { + options.onSuccess?.(); + }); + + renderWorkPoolEditForm(); + + const saveButton = screen.getByRole("button", { name: "Save" }); + await user.click(saveButton); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith({ + to: "/work-pools/work-pool/$workPoolName", + params: { workPoolName: "test-work-pool" }, + }); + }); + }); + + it("renders with null description", () => { + const workPool = createFakeWorkPool({ + name: "test-pool", + description: null, + concurrency_limit: 5, + type: "docker", + }); + + render( + + + , + ); + + const descriptionInput = screen.getByLabelText("Description (Optional)"); + expect(descriptionInput).toHaveValue(""); + }); + + it("renders with null concurrency limit", () => { + const workPool = createFakeWorkPool({ + name: "test-pool", + description: "Test", + concurrency_limit: null, + type: "kubernetes", + }); + + render( + + + , + ); + + const concurrencyInput = screen.getByLabelText( + "Flow Run Concurrency (Optional)", + ); + expect(concurrencyInput).toHaveValue(null); + }); + + it("renders Save and Cancel buttons", () => { + renderWorkPoolEditForm(); + + expect(screen.getByRole("button", { name: "Save" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument(); + }); +}); diff --git a/ui-v2/src/components/work-pools/edit/work-pool-edit-form.tsx b/ui-v2/src/components/work-pools/edit/work-pool-edit-form.tsx new file mode 100644 index 000000000000..79dfc11d8dc2 --- /dev/null +++ b/ui-v2/src/components/work-pools/edit/work-pool-edit-form.tsx @@ -0,0 +1,146 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useRouter } from "@tanstack/react-router"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { useUpdateWorkPool, type WorkPool } from "@/api/work-pools"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { type WorkPoolEditFormValues, workPoolEditSchema } from "./schema"; + +type WorkPoolEditFormProps = { + workPool: WorkPool; +}; + +export const WorkPoolEditForm = ({ workPool }: WorkPoolEditFormProps) => { + const router = useRouter(); + const { updateWorkPool, isPending } = useUpdateWorkPool(); + + const form = useForm({ + resolver: zodResolver(workPoolEditSchema), + defaultValues: { + description: workPool.description ?? "", + concurrencyLimit: workPool.concurrency_limit ?? null, + }, + }); + + const handleCancel = () => { + router.history.back(); + }; + + const handleSubmit = (data: WorkPoolEditFormValues) => { + updateWorkPool( + { + name: workPool.name, + workPool: { + description: data.description || null, + concurrency_limit: data.concurrencyLimit, + }, + }, + { + onSuccess: () => { + toast.success("Work pool updated"); + void router.navigate({ + to: "/work-pools/work-pool/$workPoolName", + params: { workPoolName: workPool.name }, + }); + }, + onError: (error) => { + toast.error( + `Failed to update work pool: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + }, + }, + ); + }; + + return ( + + +
+ void form.handleSubmit(handleSubmit)(e)} + className="space-y-6" + > +
+ + +
+ + ( + + Description (Optional) + +