diff --git a/ui-v2/src/api/deployments/index.ts b/ui-v2/src/api/deployments/index.ts index ecc078bc2dcf..54d9ffb19aeb 100644 --- a/ui-v2/src/api/deployments/index.ts +++ b/ui-v2/src/api/deployments/index.ts @@ -1,4 +1,4 @@ -import { components } from "@/api/prefect"; +import type { components } from "@/api/prefect"; import { getQueryService } from "@/api/service"; import { queryOptions } from "@tanstack/react-query"; @@ -108,6 +108,6 @@ export const buildCountDeploymentsQuery = ( const res = await getQueryService().POST("/deployments/count", { body: filter, }); - return res.data ?? []; + return res.data ?? 0; }, }); diff --git a/ui-v2/src/components/deployments/data-table/cells.tsx b/ui-v2/src/components/deployments/data-table/cells.tsx index ec4d830c062d..e36b169ddf41 100644 --- a/ui-v2/src/components/deployments/data-table/cells.tsx +++ b/ui-v2/src/components/deployments/data-table/cells.tsx @@ -9,7 +9,7 @@ import { DropdownMenuLabel, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { FlowRunActivityBarChart } from "@/components/ui/flow-run-acitivity-bar-graph"; +import { FlowRunActivityBarChart } from "@/components/ui/flow-run-activity-bar-graph"; import { Icon } from "@/components/ui/icons"; import useDebounce from "@/hooks/use-debounce"; import { useToast } from "@/hooks/use-toast"; diff --git a/ui-v2/src/components/deployments/data-table/data-table.test.tsx b/ui-v2/src/components/deployments/data-table/data-table.test.tsx index e2f097dee716..e42a3cbb9f25 100644 --- a/ui-v2/src/components/deployments/data-table/data-table.test.tsx +++ b/ui-v2/src/components/deployments/data-table/data-table.test.tsx @@ -3,6 +3,7 @@ import { createFakeFlowRunWithDeploymentAndFlow } from "@/mocks/create-fake-flow import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { buildApiUrl, createWrapper, server } from "@tests/utils"; +import { mockPointerEvents } from "@tests/utils/browser"; import { HttpResponse } from "msw"; import { http } from "msw"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -40,12 +41,17 @@ describe("DeploymentsDataTable", () => { const defaultProps = { deployments: [mockDeployment], + currentDeploymentsCount: 1, pageCount: 5, pagination: { pageSize: 10, pageIndex: 2, }, + sort: "NAME_ASC" as const, + columnFilters: [], onPaginationChange: vi.fn(), + onSortChange: vi.fn(), + onColumnFiltersChange: vi.fn(), onQuickRun: vi.fn(), onCustomRun: vi.fn(), onEdit: vi.fn(), @@ -119,6 +125,7 @@ describe("DeploymentsDataTable", () => { expect(screen.getByText("Second Deployment")).toBeInTheDocument(); expect(screen.getByText("test-flow")).toBeInTheDocument(); expect(screen.getByText("second-flow")).toBeInTheDocument(); + expect(screen.getByText("1 Deployment")).toBeInTheDocument(); }); it("calls onQuickRun when quick run action is clicked", async () => { @@ -242,4 +249,91 @@ describe("DeploymentsDataTable", () => { pageSize: 10, }); }); + + it("calls onSortChange when sort is changed", async () => { + const user = userEvent.setup(); + + mockPointerEvents(); + const onSortChange = vi.fn(); + render( + , + { + wrapper: createWrapper(), + }, + ); + + const select = screen.getByRole("combobox", { + name: "Deployment sort order", + }); + await userEvent.click(select); + await userEvent.click(screen.getByText("Created")); + + expect(onSortChange).toHaveBeenCalledWith("CREATED_DESC"); + + await user.click(select); + await user.click(screen.getByText("Updated")); + expect(onSortChange).toHaveBeenCalledWith("UPDATED_DESC"); + + await user.click(select); + await user.click(screen.getByText("Z to A")); + expect(onSortChange).toHaveBeenCalledWith("NAME_DESC"); + }); + + it("calls onColumnFiltersChange on deployment name search", async () => { + const user = userEvent.setup(); + + const onColumnFiltersChange = vi.fn(); + render( + , + { + wrapper: createWrapper(), + }, + ); + + // Clear any initial calls from mounting + onColumnFiltersChange.mockClear(); + + const nameSearchInput = screen.getByPlaceholderText("Search deployments"); + expect(nameSearchInput).toHaveValue("start value"); + + await user.clear(nameSearchInput); + await user.type(nameSearchInput, "my-deployment"); + + expect(onColumnFiltersChange).toHaveBeenCalledWith([ + { id: "flowOrDeploymentName", value: "my-deployment" }, + ]); + }); + + it("calls onColumnFiltersChange on tags search", async () => { + const user = userEvent.setup(); + + const onColumnFiltersChange = vi.fn(); + render( + , + { + wrapper: createWrapper(), + }, + ); + + // Clear any initial calls from mounting + onColumnFiltersChange.mockClear(); + + const tagsSearchInput = screen.getByPlaceholderText("Filter by tags"); + expect(await screen.findByText("tag3")).toBeVisible(); + + await user.type(tagsSearchInput, "tag4"); + await user.keyboard("{enter}"); + + expect(onColumnFiltersChange).toHaveBeenCalledWith([ + { id: "tags", value: ["tag3", "tag4"] }, + ]); + }); }); diff --git a/ui-v2/src/components/deployments/data-table/index.tsx b/ui-v2/src/components/deployments/data-table/index.tsx index a9516f3ecc1f..99286695aaa5 100644 --- a/ui-v2/src/components/deployments/data-table/index.tsx +++ b/ui-v2/src/components/deployments/data-table/index.tsx @@ -1,11 +1,25 @@ import type { DeploymentWithFlow } from "@/api/deployments"; +import type { components } from "@/api/prefect"; import { DataTable } from "@/components/ui/data-table"; -import { FlowRunActivityBarGraphTooltipProvider } from "@/components/ui/flow-run-acitivity-bar-graph"; +import { FlowRunActivityBarGraphTooltipProvider } from "@/components/ui/flow-run-activity-bar-graph"; import { Icon } from "@/components/ui/icons"; +import { SearchInput } from "@/components/ui/input"; import { ScheduleBadgeGroup } from "@/components/ui/schedule-badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { StatusBadge } from "@/components/ui/status-badge"; import { TagBadgeGroup } from "@/components/ui/tag-badge-group"; -import type { OnChangeFn, PaginationState } from "@tanstack/react-table"; +import { TagsInput } from "@/components/ui/tags-input"; +import type { + ColumnFiltersState, + OnChangeFn, + PaginationState, +} from "@tanstack/react-table"; import { createColumnHelper, getCoreRowModel, @@ -16,9 +30,14 @@ import { ActionsCell, ActivityCell } from "./cells"; type DeploymentsDataTableProps = { deployments: DeploymentWithFlow[]; + currentDeploymentsCount: number; pageCount: number; pagination: PaginationState; + sort: components["schemas"]["DeploymentSort"]; + columnFilters: ColumnFiltersState; onPaginationChange: (pagination: PaginationState) => void; + onSortChange: (sort: components["schemas"]["DeploymentSort"]) => void; + onColumnFiltersChange: (columnFilters: ColumnFiltersState) => void; onQuickRun: (deployment: DeploymentWithFlow) => void; onCustomRun: (deployment: DeploymentWithFlow) => void; onEdit: (deployment: DeploymentWithFlow) => void; @@ -120,15 +139,50 @@ const createColumns = ({ export const DeploymentsDataTable = ({ deployments, + currentDeploymentsCount, pagination, pageCount, + sort, + columnFilters, onPaginationChange, + onSortChange, + onColumnFiltersChange, onQuickRun, onCustomRun, onEdit, onDelete, onDuplicate, }: DeploymentsDataTableProps) => { + const nameSearchValue = (columnFilters.find( + (filter) => filter.id === "flowOrDeploymentName", + )?.value ?? "") as string; + const tagsSearchValue = (columnFilters.find((filter) => filter.id === "tags") + ?.value ?? []) as string[]; + + const handleNameSearchChange = useCallback( + (value?: string) => { + const filters = columnFilters.filter( + (filter) => filter.id !== "flowOrDeploymentName", + ); + onColumnFiltersChange( + value ? [...filters, { id: "flowOrDeploymentName", value }] : filters, + ); + }, + [onColumnFiltersChange, columnFilters], + ); + + const handleTagsSearchChange: React.ChangeEventHandler & + ((tags: string[]) => void) = useCallback( + (e: string[] | React.ChangeEvent) => { + const tags = Array.isArray(e) ? e : e.target.value; + const filters = columnFilters.filter((filter) => filter.id !== "tags"); + onColumnFiltersChange( + tags.length ? [...filters, { id: "tags", value: tags }] : filters, + ); + }, + [onColumnFiltersChange, columnFilters], + ); + const handlePaginationChange: OnChangeFn = useCallback( (updater) => { let newPagination = pagination; @@ -163,8 +217,46 @@ export const DeploymentsDataTable = ({ onPaginationChange: handlePaginationChange, }); return ( - - - + + + + + {currentDeploymentsCount} Deployment + {currentDeploymentsCount === 1 ? "" : "s"} + + + + handleNameSearchChange(e.target.value)} + /> + + + + + + + + + + + Created + Updated + A to Z + Z to A + + + + + + + + + ); }; diff --git a/ui-v2/src/components/ui/flow-run-acitivity-bar-graph/context.tsx b/ui-v2/src/components/ui/flow-run-activity-bar-graph/context.tsx similarity index 100% rename from ui-v2/src/components/ui/flow-run-acitivity-bar-graph/context.tsx rename to ui-v2/src/components/ui/flow-run-activity-bar-graph/context.tsx diff --git a/ui-v2/src/components/ui/flow-run-acitivity-bar-graph/flow-run-activity-bar-graph.stories.tsx b/ui-v2/src/components/ui/flow-run-activity-bar-graph/flow-run-activity-bar-graph.stories.tsx similarity index 100% rename from ui-v2/src/components/ui/flow-run-acitivity-bar-graph/flow-run-activity-bar-graph.stories.tsx rename to ui-v2/src/components/ui/flow-run-activity-bar-graph/flow-run-activity-bar-graph.stories.tsx diff --git a/ui-v2/src/components/ui/flow-run-acitivity-bar-graph/flow-run-activity-bar-graph.test.tsx b/ui-v2/src/components/ui/flow-run-activity-bar-graph/flow-run-activity-bar-graph.test.tsx similarity index 100% rename from ui-v2/src/components/ui/flow-run-acitivity-bar-graph/flow-run-activity-bar-graph.test.tsx rename to ui-v2/src/components/ui/flow-run-activity-bar-graph/flow-run-activity-bar-graph.test.tsx diff --git a/ui-v2/src/components/ui/flow-run-acitivity-bar-graph/index.tsx b/ui-v2/src/components/ui/flow-run-activity-bar-graph/index.tsx similarity index 100% rename from ui-v2/src/components/ui/flow-run-acitivity-bar-graph/index.tsx rename to ui-v2/src/components/ui/flow-run-activity-bar-graph/index.tsx diff --git a/ui-v2/src/components/ui/flow-run-acitivity-bar-graph/utils.test.ts b/ui-v2/src/components/ui/flow-run-activity-bar-graph/utils.test.ts similarity index 100% rename from ui-v2/src/components/ui/flow-run-acitivity-bar-graph/utils.test.ts rename to ui-v2/src/components/ui/flow-run-activity-bar-graph/utils.test.ts diff --git a/ui-v2/src/components/ui/flow-run-acitivity-bar-graph/utils.ts b/ui-v2/src/components/ui/flow-run-activity-bar-graph/utils.ts similarity index 100% rename from ui-v2/src/components/ui/flow-run-acitivity-bar-graph/utils.ts rename to ui-v2/src/components/ui/flow-run-activity-bar-graph/utils.ts diff --git a/ui-v2/src/routes/deployments/index.tsx b/ui-v2/src/routes/deployments/index.tsx index 1d75df3889b0..27021a228170 100644 --- a/ui-v2/src/routes/deployments/index.tsx +++ b/ui-v2/src/routes/deployments/index.tsx @@ -4,12 +4,16 @@ import { buildPaginateDeploymentsQuery, } from "@/api/deployments"; import { buildListFlowsQuery } from "@/api/flows"; +import type { components } from "@/api/prefect"; import { DeploymentsDataTable } from "@/components/deployments/data-table"; import { DeploymentsEmptyState } from "@/components/deployments/empty-state"; import { DeploymentsPageHeader } from "@/components/deployments/header"; import { useQuery, useSuspenseQueries } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; -import type { PaginationState } from "@tanstack/react-table"; +import type { + ColumnFiltersState, + PaginationState, +} from "@tanstack/react-table"; import { zodValidator } from "@tanstack/zod-adapter"; import { useCallback, useMemo } from "react"; import { z } from "zod"; @@ -21,6 +25,13 @@ import { z } from "zod"; const searchParams = z.object({ page: z.number().int().positive().optional().default(1).catch(1), limit: z.number().int().positive().optional().default(10).catch(10), + sort: z + .enum(["NAME_ASC", "NAME_DESC", "CREATED_DESC", "UPDATED_DESC"]) + .optional() + .default("NAME_ASC") + .catch("NAME_ASC"), + flowOrDeploymentName: z.string().optional().catch(""), + tags: z.array(z.string()).optional().catch([]), }); /** @@ -40,7 +51,12 @@ const buildPaginationBody = ( ): DeploymentsPaginationFilter => ({ page: search?.page ?? 1, limit: search?.limit ?? 10, - sort: "NAME_ASC", + sort: search?.sort ?? "NAME_ASC", + deployments: { + operator: "and_", + flow_or_deployment_name: { like_: search?.flowOrDeploymentName ?? "" }, + tags: { operator: "and_", all_: search?.tags ?? [] }, + }, }); export const Route = createFileRoute("/deployments/")({ @@ -130,9 +146,73 @@ const usePagination = () => { return [pagination, onPaginationChange] as const; }; +/** + * Hook to manage sorting state and navigation for deployments table + * + * Handles updating the URL search parameters when sort order changes. + * Returns the current sort value from URL and a callback to update it. + * + * @returns A tuple containing: + * - sort: Current sort value from URL search params + * - onSortingChange: Callback to update sort and navigate with new search params + */ +const useSort = () => { + const search = Route.useSearch(); + const navigate = Route.useNavigate(); + + const onSortingChange = (sort: components["schemas"]["DeploymentSort"]) => { + void navigate({ + to: ".", + search: (prev) => ({ ...prev, sort }), + replace: true, + }); + }; + + return [search.sort, onSortingChange] as const; +}; + +const useDeploymentsColumnFilters = () => { + const search = Route.useSearch(); + const navigate = Route.useNavigate(); + const columnFilters: ColumnFiltersState = useMemo( + () => [ + { id: "flowOrDeploymentName", value: search.flowOrDeploymentName }, + { id: "tags", value: search.tags }, + ], + [search.flowOrDeploymentName, search.tags], + ); + + const onColumnFiltersChange = useCallback( + (newColumnFilters: ColumnFiltersState) => { + void navigate({ + to: ".", + search: (prev) => { + const flowOrDeploymentName = newColumnFilters.find( + (filter) => filter.id === "flowOrDeploymentName", + )?.value as string | undefined; + const tags = newColumnFilters.find((filter) => filter.id === "tags") + ?.value as string[] | undefined; + return { + ...prev, + offset: 0, + flowOrDeploymentName, + tags, + }; + }, + replace: true, + }); + }, + [navigate], + ); + + return [columnFilters, onColumnFiltersChange] as const; +}; + function RouteComponent() { const search = Route.useSearch(); const [pagination, onPaginationChange] = usePagination(); + const [sort, onSortChange] = useSort(); + const [columnFilters, onColumnFiltersChange] = useDeploymentsColumnFilters(); const [{ data: deploymentsCount }, { data: deploymentsPage }] = useSuspenseQueries({ @@ -172,9 +252,14 @@ function RouteComponent() { ) : ( console.log(deployment)} onCustomRun={(deployment) => console.log(deployment)} diff --git a/ui-v2/tests/utils/browser.ts b/ui-v2/tests/utils/browser.ts new file mode 100644 index 000000000000..e73088f9c1d8 --- /dev/null +++ b/ui-v2/tests/utils/browser.ts @@ -0,0 +1,22 @@ +import { vi } from "vitest"; + +export const mockPointerEvents = () => { + // Need to mock PointerEvent for the selects to work + class MockPointerEvent extends Event { + button: number; + ctrlKey: boolean; + pointerType: string; + + constructor(type: string, props: PointerEventInit) { + super(type, props); + this.button = props.button || 0; + this.ctrlKey = props.ctrlKey || false; + this.pointerType = props.pointerType || "mouse"; + } + } + window.PointerEvent = + MockPointerEvent as unknown as typeof window.PointerEvent; + window.HTMLElement.prototype.scrollIntoView = vi.fn(); + window.HTMLElement.prototype.releasePointerCapture = vi.fn(); + window.HTMLElement.prototype.hasPointerCapture = vi.fn(); +}; diff --git a/ui-v2/tests/variables/variables.test.tsx b/ui-v2/tests/variables/variables.test.tsx index c37195df8cfa..61463bdc39cd 100644 --- a/ui-v2/tests/variables/variables.test.tsx +++ b/ui-v2/tests/variables/variables.test.tsx @@ -12,6 +12,7 @@ import { } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { buildApiUrl, createWrapper, server } from "@tests/utils"; +import { mockPointerEvents } from "@tests/utils/browser"; import { http, HttpResponse } from "msw"; import { afterEach, @@ -277,24 +278,7 @@ describe("Variables page", () => { describe("Variables table", () => { beforeAll(() => { - // Need to mock PointerEvent for the selects to work - class MockPointerEvent extends Event { - button: number; - ctrlKey: boolean; - pointerType: string; - - constructor(type: string, props: PointerEventInit) { - super(type, props); - this.button = props.button || 0; - this.ctrlKey = props.ctrlKey || false; - this.pointerType = props.pointerType || "mouse"; - } - } - window.PointerEvent = - MockPointerEvent as unknown as typeof window.PointerEvent; - window.HTMLElement.prototype.scrollIntoView = vi.fn(); - window.HTMLElement.prototype.releasePointerCapture = vi.fn(); - window.HTMLElement.prototype.hasPointerCapture = vi.fn(); + mockPointerEvents(); }); const originalToLocaleString = Date.prototype.toLocaleString; // eslint-disable-line @typescript-eslint/unbound-method beforeEach(() => {
+ {currentDeploymentsCount} Deployment + {currentDeploymentsCount === 1 ? "" : "s"} +