diff --git a/ui-v2/src/components/concurrency/concurrency-tabs.tsx b/ui-v2/src/components/concurrency/concurrency-tabs.tsx index 49fa4d3b9760..03789c9d78de 100644 --- a/ui-v2/src/components/concurrency/concurrency-tabs.tsx +++ b/ui-v2/src/components/concurrency/concurrency-tabs.tsx @@ -39,17 +39,16 @@ export const ConcurrencyTabs = ({ const navigate = routeApi.useNavigate(); return ( - + { void navigate({ to: "/concurrency-limits", - search: (prev) => ({ - ...prev, + search: { tab: TAB_OPTIONS.global.tabSearchValue, - }), + }, }); }} > @@ -60,10 +59,9 @@ export const ConcurrencyTabs = ({ onClick={() => { void navigate({ to: "/concurrency-limits", - search: (prev) => ({ - ...prev, + search: { tab: TAB_OPTIONS["task-run"].tabSearchValue, - }), + }, }); }} > diff --git a/ui-v2/src/components/concurrency/global-concurrency-view/data-table/data-table.tsx b/ui-v2/src/components/concurrency/global-concurrency-view/data-table/data-table.tsx index a3b893c61bcd..77ae3de0184d 100644 --- a/ui-v2/src/components/concurrency/global-concurrency-view/data-table/data-table.tsx +++ b/ui-v2/src/components/concurrency/global-concurrency-view/data-table/data-table.tsx @@ -1,14 +1,20 @@ import { DataTable } from "@/components/ui/data-table"; import { type GlobalConcurrencyLimit } from "@/hooks/global-concurrency-limits"; +import { getRouteApi } from "@tanstack/react-router"; import { createColumnHelper, getCoreRowModel, + getPaginationRowModel, useReactTable, } from "@tanstack/react-table"; +import { SearchInput } from "@/components/ui/input"; +import { useDeferredValue, useMemo } from "react"; import { ActionsCell } from "./actions-cell"; import { ActiveCell } from "./active-cell"; +const routeApi = getRouteApi("/concurrency-limits"); + const columnHelper = createColumnHelper(); const createColumns = ({ @@ -53,11 +59,36 @@ export const GlobalConcurrencyDataTable = ({ onEditRow, onDeleteRow, }: Props) => { + const navigate = routeApi.useNavigate(); + const { search } = routeApi.useSearch(); + const deferredSearch = useDeferredValue(search ?? ""); + + const filteredData = useMemo(() => { + return data.filter((row) => + row.name.toLowerCase().includes(deferredSearch.toLowerCase()), + ); + }, [data, deferredSearch]); + const table = useReactTable({ - data, + data: filteredData, columns: createColumns({ onEditRow, onDeleteRow }), getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), //load client-side pagination code }); - return ; + return ( +
+ + void navigate({ + to: ".", + search: (prev) => ({ ...prev, search: e.target.value }), + }) + } + /> + +
+ ); }; diff --git a/ui-v2/src/components/concurrency/global-concurrency-view/dialog/delete-limit-dialog.tsx b/ui-v2/src/components/concurrency/global-concurrency-view/dialog/delete-limit-dialog.tsx index 42e20176a593..510c695e7709 100644 --- a/ui-v2/src/components/concurrency/global-concurrency-view/dialog/delete-limit-dialog.tsx +++ b/ui-v2/src/components/concurrency/global-concurrency-view/dialog/delete-limit-dialog.tsx @@ -27,7 +27,7 @@ export const DeleteLimitDialog = ({ limit, onOpenChange, onDelete }: Props) => { const handleOnClick = (id: string | undefined) => { if (!id) { - throw new Error("'id' field expected in GlobalConcurrencyLimit"); + throw new Error("'id' field expected in TaskRunConcurrencyLimit"); } deleteGlobalConcurrencyLimit(id, { onSuccess: () => { diff --git a/ui-v2/src/components/concurrency/global-concurrency-view/global-concurrency-limits-header.tsx b/ui-v2/src/components/concurrency/global-concurrency-view/global-concurrency-limits-header.tsx index 9f10f79f4062..f5f057d3e25c 100644 --- a/ui-v2/src/components/concurrency/global-concurrency-view/global-concurrency-limits-header.tsx +++ b/ui-v2/src/components/concurrency/global-concurrency-view/global-concurrency-limits-header.tsx @@ -10,8 +10,12 @@ export const GlobalConcurrencyLimitsHeader = ({ onAdd }: Props) => { return (
Global Concurrency Limits -
); diff --git a/ui-v2/src/components/concurrency/global-concurrency-view/index.tsx b/ui-v2/src/components/concurrency/global-concurrency-view/index.tsx index 77c3f94d6500..11d9d1a31a99 100644 --- a/ui-v2/src/components/concurrency/global-concurrency-view/index.tsx +++ b/ui-v2/src/components/concurrency/global-concurrency-view/index.tsx @@ -37,24 +37,22 @@ export const GlobalConcurrencyView = () => { }; return ( - <> +
+ {data.length === 0 ? ( ) : ( -
- - -
+ )} - +
); }; diff --git a/ui-v2/src/components/concurrency/task-run-concurrenct-view/data-table/actions-cell.tsx b/ui-v2/src/components/concurrency/task-run-concurrenct-view/data-table/actions-cell.tsx new file mode 100644 index 000000000000..5e5b07daee7e --- /dev/null +++ b/ui-v2/src/components/concurrency/task-run-concurrenct-view/data-table/actions-cell.tsx @@ -0,0 +1,56 @@ +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Icon } from "@/components/ui/icons"; +import { type TaskRunConcurrencyLimit } from "@/hooks/task-run-concurrency-limits"; +import { useToast } from "@/hooks/use-toast"; +import { CellContext } from "@tanstack/react-table"; + +type Props = CellContext & { + onDeleteRow: (row: TaskRunConcurrencyLimit) => void; + onResetRow: (row: TaskRunConcurrencyLimit) => void; +}; + +export const ActionsCell = ({ onDeleteRow, onResetRow, ...props }: Props) => { + const { toast } = useToast(); + + const handleCopyId = (id: string | undefined) => { + if (!id) { + throw new Error("'id' field expected in GlobalConcurrencyLimit"); + } + void navigator.clipboard.writeText(id); + toast({ title: "Name copied" }); + }; + + const row = props.row.original; + + return ( +
+ + + + + + Actions + handleCopyId(row.id)}> + Copy ID + + onDeleteRow(row)}> + Delete + + onResetRow(row)}> + Reset + + + +
+ ); +}; diff --git a/ui-v2/src/components/concurrency/task-run-concurrenct-view/data-table/data-table.tsx b/ui-v2/src/components/concurrency/task-run-concurrenct-view/data-table/data-table.tsx new file mode 100644 index 000000000000..1fda29f44fd7 --- /dev/null +++ b/ui-v2/src/components/concurrency/task-run-concurrenct-view/data-table/data-table.tsx @@ -0,0 +1,90 @@ +import { DataTable } from "@/components/ui/data-table"; +import { type TaskRunConcurrencyLimit } from "@/hooks/task-run-concurrency-limits"; +import { getRouteApi } from "@tanstack/react-router"; +import { + createColumnHelper, + getCoreRowModel, + getPaginationRowModel, + useReactTable, +} from "@tanstack/react-table"; + +import { SearchInput } from "@/components/ui/input"; +import { useDeferredValue, useMemo } from "react"; +import { ActionsCell } from "./actions-cell"; + +const routeApi = getRouteApi("/concurrency-limits"); + +const columnHelper = createColumnHelper(); + +const createColumns = ({ + onDeleteRow, + onResetRow, +}: { + onDeleteRow: (row: TaskRunConcurrencyLimit) => void; + onResetRow: (row: TaskRunConcurrencyLimit) => void; +}) => [ + columnHelper.accessor("tag", { + header: "Tag", // TODO: Make this a link when starting the tak run concurrency page + }), + columnHelper.accessor("concurrency_limit", { + header: "Slots", + }), + columnHelper.accessor("active_slots", { + header: "Active Task Runs", // TODO: Give this styling once knowing what it looks like + }), + columnHelper.display({ + id: "actions", + cell: (props) => ( + + ), + }), +]; + +type Props = { + data: Array; + onDeleteRow: (row: TaskRunConcurrencyLimit) => void; + onResetRow: (row: TaskRunConcurrencyLimit) => void; +}; + +export const TaskRunConcurrencyDataTable = ({ + data, + onDeleteRow, + onResetRow, +}: Props) => { + const navigate = routeApi.useNavigate(); + const { search } = routeApi.useSearch(); + const deferredSearch = useDeferredValue(search ?? ""); + + const filteredData = useMemo(() => { + return data.filter((row) => + row.tag.toLowerCase().includes(deferredSearch.toLowerCase()), + ); + }, [data, deferredSearch]); + + const table = useReactTable({ + data: filteredData, + columns: createColumns({ onDeleteRow, onResetRow }), + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), //load client-side pagination code + }); + + return ( +
+ + void navigate({ + to: ".", + search: (prev) => ({ ...prev, search: e.target.value }), + }) + } + /> + +
+ ); +}; diff --git a/ui-v2/src/components/concurrency/task-run-concurrenct-view/data-table/index.ts b/ui-v2/src/components/concurrency/task-run-concurrenct-view/data-table/index.ts new file mode 100644 index 000000000000..be5bcc6d4a6b --- /dev/null +++ b/ui-v2/src/components/concurrency/task-run-concurrenct-view/data-table/index.ts @@ -0,0 +1 @@ +export { TaskRunConcurrencyDataTable } from "./data-table"; diff --git a/ui-v2/src/components/concurrency/task-run-concurrenct-view/dialogs/create-dialog.tsx b/ui-v2/src/components/concurrency/task-run-concurrenct-view/dialogs/create-dialog.tsx new file mode 100644 index 000000000000..939eefe09d66 --- /dev/null +++ b/ui-v2/src/components/concurrency/task-run-concurrenct-view/dialogs/create-dialog.tsx @@ -0,0 +1,124 @@ +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { useCreateTaskRunConcurrencyLimit } from "@/hooks/task-run-concurrency-limits"; +import { useToast } from "@/hooks/use-toast"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +const formSchema = z.object({ + tag: z.string().min(1), + /** Coerce to solve common issue of transforming a string number to a number type */ + concurrency_limit: z + .number() + .default(0) + .or(z.string()) + .pipe(z.coerce.number()), +}); + +const DEFAULT_VALUES = { + tag: "", + concurrency_limit: 0, +} as const; + +type Props = { + onOpenChange: (open: boolean) => void; + onSubmit: () => void; +}; + +export const CreateLimitDialog = ({ onOpenChange, onSubmit }: Props) => { + const { toast } = useToast(); + + const { createTaskRunConcurrencyLimit, isPending } = + useCreateTaskRunConcurrencyLimit(); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: DEFAULT_VALUES, + }); + + const handleAddLimit = (values: z.infer) => { + createTaskRunConcurrencyLimit(values, { + onSuccess: () => { + toast({ title: "Concurrency limit added" }); + }, + onError: (error) => { + const message = error.message || "Unknown error while updating limit."; + form.setError("root", { message }); + }, + onSettled: () => { + form.reset(DEFAULT_VALUES); + onSubmit(); + }, + }); + }; + + return ( + + + + Add Task Run Concurrency Limit + + +
+ void form.handleSubmit(handleAddLimit)(e)} + className="space-y-4" + > + {form.formState.errors.root?.message} + ( + + Tag + + + + + + )} + /> + ( + + Concurrency Limit + + + + + + )} + /> + + + + + + + + +
+
+ ); +}; diff --git a/ui-v2/src/components/concurrency/task-run-concurrenct-view/dialogs/delete-dialog.tsx b/ui-v2/src/components/concurrency/task-run-concurrenct-view/dialogs/delete-dialog.tsx new file mode 100644 index 000000000000..8131c6ed85db --- /dev/null +++ b/ui-v2/src/components/concurrency/task-run-concurrenct-view/dialogs/delete-dialog.tsx @@ -0,0 +1,69 @@ +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + TaskRunConcurrencyLimit, + useDeleteTaskRunConcurrencyLimit, +} from "@/hooks/task-run-concurrency-limits"; +import { useToast } from "@/hooks/use-toast"; + +type Props = { + data: TaskRunConcurrencyLimit; + onOpenChange: (open: boolean) => void; + onDelete: () => void; +}; + +export const DeleteLimitDialog = ({ data, onOpenChange, onDelete }: Props) => { + const { toast } = useToast(); + const { deleteTaskRunConcurrencyLimit, isPending } = + useDeleteTaskRunConcurrencyLimit(); + + const handleOnClick = (id: string | undefined) => { + if (!id) { + throw new Error("'id' field expected in GlobalConcurrencyLimit"); + } + deleteTaskRunConcurrencyLimit(id, { + onSuccess: () => { + toast({ description: "Concurrency limit deleted" }); + }, + onError: (error) => { + const message = + error.message || "Unknown error while deleting concurrency limit."; + console.error(message); + }, + onSettled: onDelete, + }); + }; + + return ( + + + + Delete Concurrency Limit + + + Are you sure you want to delete {data.tag} + + + + + + + + + + ); +}; diff --git a/ui-v2/src/components/concurrency/task-run-concurrenct-view/dialogs/index.tsx b/ui-v2/src/components/concurrency/task-run-concurrenct-view/dialogs/index.tsx new file mode 100644 index 000000000000..3089fea82076 --- /dev/null +++ b/ui-v2/src/components/concurrency/task-run-concurrenct-view/dialogs/index.tsx @@ -0,0 +1,51 @@ +import { TaskRunConcurrencyLimit } from "@/hooks/task-run-concurrency-limits"; + +import { CreateLimitDialog } from "./create-dialog"; +import { DeleteLimitDialog } from "./delete-dialog"; +import { ResetLimitDialog } from "./reset-dialog"; + +export type DialogState = + | { dialog: null | "create"; data: undefined } + | { + dialog: "reset" | "delete"; + data: TaskRunConcurrencyLimit; + }; + +export const DialogView = ({ + openDialog, + onCloseDialog, + onOpenChange, +}: { + openDialog: DialogState; + onOpenChange: (open: boolean) => void; + onCloseDialog: () => void; +}) => { + const { dialog, data } = openDialog; + switch (dialog) { + case "create": + return ( + + ); + case "reset": + return ( + + ); + case "delete": + return ( + + ); + default: + return null; + } +}; diff --git a/ui-v2/src/components/concurrency/task-run-concurrenct-view/dialogs/reset-dialog.tsx b/ui-v2/src/components/concurrency/task-run-concurrenct-view/dialogs/reset-dialog.tsx new file mode 100644 index 000000000000..c9ec79fe9be6 --- /dev/null +++ b/ui-v2/src/components/concurrency/task-run-concurrenct-view/dialogs/reset-dialog.tsx @@ -0,0 +1,62 @@ +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + TaskRunConcurrencyLimit, + useResetTaskRunConcurrencyLimitTag, +} from "@/hooks/task-run-concurrency-limits"; +import { useToast } from "@/hooks/use-toast"; + +type Props = { + data: TaskRunConcurrencyLimit; + onOpenChange: (open: boolean) => void; + onReset: () => void; +}; + +export const ResetLimitDialog = ({ data, onOpenChange, onReset }: Props) => { + const { toast } = useToast(); + const { resetTaskRunConcurrencyLimitTag, isPending } = + useResetTaskRunConcurrencyLimitTag(); + + const handleOnClick = (tag: string) => { + resetTaskRunConcurrencyLimitTag(tag, { + onSuccess: () => { + toast({ description: "Concurrency limit reset" }); + }, + onError: (error) => { + const message = + error.message || "Unknown error while resetting concurrency limit."; + console.error(message); + }, + onSettled: onReset, + }); + }; + + return ( + + + + Reset concurrency limit for tag {data.tag} + + + This will reset the active task run count to 0. + + + + + + + + + + ); +}; diff --git a/ui-v2/src/components/concurrency/task-run-concurrenct-view/empty-state/index.ts b/ui-v2/src/components/concurrency/task-run-concurrenct-view/empty-state/index.ts new file mode 100644 index 000000000000..75c9c520c471 --- /dev/null +++ b/ui-v2/src/components/concurrency/task-run-concurrenct-view/empty-state/index.ts @@ -0,0 +1 @@ +export { TaskRunConcurrencyLimitEmptyState } from "./task-run-concurrency-limit-empty-state"; diff --git a/ui-v2/src/components/concurrency/task-run-concurrenct-view/task-run-concurrency-limit-empty-state.test.tsx b/ui-v2/src/components/concurrency/task-run-concurrenct-view/empty-state/task-run-concurrency-limit-empty-state.test.tsx similarity index 100% rename from ui-v2/src/components/concurrency/task-run-concurrenct-view/task-run-concurrency-limit-empty-state.test.tsx rename to ui-v2/src/components/concurrency/task-run-concurrenct-view/empty-state/task-run-concurrency-limit-empty-state.test.tsx diff --git a/ui-v2/src/components/concurrency/task-run-concurrenct-view/task-run-concurrency-limit-empty-state.tsx b/ui-v2/src/components/concurrency/task-run-concurrenct-view/empty-state/task-run-concurrency-limit-empty-state.tsx similarity index 100% rename from ui-v2/src/components/concurrency/task-run-concurrenct-view/task-run-concurrency-limit-empty-state.tsx rename to ui-v2/src/components/concurrency/task-run-concurrenct-view/empty-state/task-run-concurrency-limit-empty-state.tsx diff --git a/ui-v2/src/components/concurrency/task-run-concurrenct-view/header.tsx b/ui-v2/src/components/concurrency/task-run-concurrenct-view/header.tsx new file mode 100644 index 000000000000..ba5c1d9f64ce --- /dev/null +++ b/ui-v2/src/components/concurrency/task-run-concurrenct-view/header.tsx @@ -0,0 +1,22 @@ +import { Button } from "@/components/ui/button"; +import { Icon } from "@/components/ui/icons"; +import { Typography } from "@/components/ui/typography"; + +type Props = { + onAdd: () => void; +}; + +export const TaskRunConcurrencyLimitsHeader = ({ onAdd }: Props) => { + return ( +
+ Task Run Concurrency Limits + +
+ ); +}; diff --git a/ui-v2/src/components/concurrency/task-run-concurrenct-view/index.tsx b/ui-v2/src/components/concurrency/task-run-concurrenct-view/index.tsx index 8902ed44ec37..20e073d877b5 100644 --- a/ui-v2/src/components/concurrency/task-run-concurrenct-view/index.tsx +++ b/ui-v2/src/components/concurrency/task-run-concurrenct-view/index.tsx @@ -1,3 +1,59 @@ +import { + TaskRunConcurrencyLimit, + useListTaskRunConcurrencyLimits, +} from "@/hooks/task-run-concurrency-limits"; +import { useState } from "react"; +import { TaskRunConcurrencyDataTable } from "./data-table"; +import { DialogState, DialogView } from "./dialogs"; +import { TaskRunConcurrencyLimitEmptyState } from "./empty-state"; +import { TaskRunConcurrencyLimitsHeader } from "./header"; + export const TaskRunConcurrencyView = () => { - return
🚧🚧 Pardon our dust! 🚧🚧
; + const [openDialog, setOpenDialog] = useState({ + dialog: null, + data: undefined, + }); + + const { data } = useListTaskRunConcurrencyLimits(); + + const handleAddRow = () => + setOpenDialog({ dialog: "create", data: undefined }); + + const handleDeleteRow = (data: TaskRunConcurrencyLimit) => + setOpenDialog({ dialog: "delete", data }); + + const handleResetRow = (data: TaskRunConcurrencyLimit) => + setOpenDialog({ dialog: "reset", data }); + + const handleCloseDialog = () => + setOpenDialog({ dialog: null, data: undefined }); + + // Because all modals will be rendered, only control the closing logic + const handleOpenChange = (open: boolean) => { + if (!open) { + handleCloseDialog(); + } + }; + + return ( + <> +
+ + {data.length === 0 ? ( + + ) : ( + + )} + +
+ + ); }; diff --git a/ui-v2/src/hooks/global-concurrency-limits.ts b/ui-v2/src/hooks/global-concurrency-limits.ts index cf3c5473a62e..8fbd00e32475 100644 --- a/ui-v2/src/hooks/global-concurrency-limits.ts +++ b/ui-v2/src/hooks/global-concurrency-limits.ts @@ -1,7 +1,6 @@ import type { components } from "@/api/prefect"; import { getQueryService } from "@/api/service"; import { - QueryClient, queryOptions, useMutation, useQueryClient, @@ -55,13 +54,6 @@ export const useListGlobalConcurrencyLimits = ( filter: GlobalConcurrencyLimitsFilter = { offset: 0 }, ) => useSuspenseQuery(buildListGlobalConcurrencyLimitsQuery(filter)); -useListGlobalConcurrencyLimits.loader = ({ - context, -}: { - context: { queryClient: QueryClient }; -}) => - context.queryClient.ensureQueryData(buildListGlobalConcurrencyLimitsQuery()); - // ----- ✍🏼 Mutations 🗄️ // ---------------------------- @@ -127,7 +119,7 @@ export const useDeleteGlobalConcurrencyLimit = () => { * }, * onError: (error) => { * // Handle error - * console.error('Failed to global concurrency limit:', error); + * console.error('Failed to create global concurrency limit:', error); * } * }); * ``` @@ -178,7 +170,7 @@ type GlobalConcurrencyLimitUpdateWithId = * // Handle successful update * }, * onError: (error) => { - * console.error('Failed to update global concurrency limit:', error); + * console.error('Failed to update global concurrency limit:', error); * } * }); * ``` diff --git a/ui-v2/src/hooks/task-run-concurrency-limits.test.tsx b/ui-v2/src/hooks/task-run-concurrency-limits.test.tsx new file mode 100644 index 000000000000..f0aa58522b8d --- /dev/null +++ b/ui-v2/src/hooks/task-run-concurrency-limits.test.tsx @@ -0,0 +1,248 @@ +import { QueryClient } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { createWrapper, server } from "@tests/utils"; +import { http, HttpResponse } from "msw"; +import { describe, expect, it } from "vitest"; +import { + type TaskRunConcurrencyLimit, + queryKeyFactory, + useCreateTaskRunConcurrencyLimit, + useDeleteTaskRunConcurrencyLimit, + useGetTaskRunConcurrencyLimit, + useListTaskRunConcurrencyLimits, + useResetTaskRunConcurrencyLimitTag, +} from "./task-run-concurrency-limits"; + +describe("task run concurrency limits hooks", () => { + const seedData = () => [ + { + id: "0", + created: "2021-01-01T00:00:00Z", + updated: "2021-01-01T00:00:00Z", + tag: "my tag 0", + concurrency_limit: 1, + active_slots: [] as Array, + }, + ]; + + const mockFetchDetailsAPI = (data: TaskRunConcurrencyLimit) => { + server.use( + http.get("http://localhost:4200/api/concurrency_limits/:id", () => { + return HttpResponse.json(data); + }), + ); + }; + + const mockFetchListAPI = (data: Array) => { + server.use( + http.post("http://localhost:4200/api/concurrency_limits/filter", () => { + return HttpResponse.json(data); + }), + ); + }; + + const mockCreateAPI = (data: TaskRunConcurrencyLimit) => { + server.use( + http.post("http://localhost:4200/api/concurrency_limits/", () => { + return HttpResponse.json(data, { status: 201 }); + }), + ); + }; + + const filter = { + offset: 0, + }; + + /** + * Data Management: + * - Asserts task run concurrency limit list data is fetched based on the APIs invoked for the hook + */ + it("useListTaskRunConcurrencyLimits() stores list data into the appropriate list query", async () => { + // ------------ Mock API requests when cache is empty + const mockList = seedData(); + mockFetchListAPI(seedData()); + + // ------------ Initialize hooks to test + const { result } = renderHook( + () => useListTaskRunConcurrencyLimits(filter), + { wrapper: createWrapper({}) }, + ); + + // ------------ Assert + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual(mockList); + }); + + /** + * Data Management: + * - Asserts task run concurrency limit detail data is fetched based on the APIs invoked for the hook + */ + it("useGetTaskRunConcurrencyLimit() stores details data into the appropriate details query", async () => { + // ------------ Mock API requests when cache is empty + const mockData = seedData()[0]; + mockFetchDetailsAPI(mockData); + + // ------------ Initialize hooks to test + const { result } = renderHook( + () => useGetTaskRunConcurrencyLimit(mockData.id), + { wrapper: createWrapper({}) }, + ); + + // ------------ Assert + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toMatchObject(mockData); + }); + + /** + * Data Management: + * - Asserts task run concurrency limit calls delete API and refetches updated list + */ + it("useDeleteTaskRunConcurrencyLimit() invalidates cache and fetches updated value", async () => { + const ID_TO_DELETE = "0"; + const queryClient = new QueryClient(); + + // ------------ Mock API requests after queries are invalidated + const mockData = seedData().filter((limit) => limit.id !== ID_TO_DELETE); + mockFetchListAPI(mockData); + + // ------------ Initialize cache + queryClient.setQueryData(queryKeyFactory.list(filter), seedData()); + + // ------------ Initialize hooks to test + const { result: useListTaskRunConcurrencyLimitsResult } = renderHook( + () => useListTaskRunConcurrencyLimits(filter), + { wrapper: createWrapper({ queryClient }) }, + ); + + const { result: useDeleteTaskRunConcurrencyLimitResult } = renderHook( + useDeleteTaskRunConcurrencyLimit, + { wrapper: createWrapper({ queryClient }) }, + ); + + // ------------ Invoke mutation + act(() => + useDeleteTaskRunConcurrencyLimitResult.current.deleteTaskRunConcurrencyLimit( + ID_TO_DELETE, + ), + ); + + // ------------ Assert + await waitFor(() => + expect(useDeleteTaskRunConcurrencyLimitResult.current.isSuccess).toBe( + true, + ), + ); + expect(useListTaskRunConcurrencyLimitsResult.current.data).toHaveLength(0); + }); + + /** + * Data Management: + * - Asserts create mutation API is called. + * - Upon create mutation API being called, cache is invalidated and asserts cache invalidation APIS are called + */ + it("useCreateTaskRunConcurrencyLimit() invalidates cache and fetches updated value", async () => { + const queryClient = new QueryClient(); + const MOCK_NEW_DATA_ID = "1"; + const MOCK_NEW_DATA = { + tag: "my tag 1", + concurrency_limit: 2, + active_slots: [], + }; + + // ------------ Mock API requests after queries are invalidated + const NEW_LIMIT_DATA = { + ...MOCK_NEW_DATA, + id: MOCK_NEW_DATA_ID, + created: "2021-01-01T00:00:00Z", + updated: "2021-01-01T00:00:00Z", + }; + + const mockData = [...seedData(), NEW_LIMIT_DATA]; + mockFetchListAPI(mockData); + mockCreateAPI(NEW_LIMIT_DATA); + + // ------------ Initialize cache + queryClient.setQueryData(queryKeyFactory.list(filter), seedData()); + + // ------------ Initialize hooks to test + const { result: useListTaskRunConcurrencyLimitsResult } = renderHook( + () => useListTaskRunConcurrencyLimits(filter), + { wrapper: createWrapper({ queryClient }) }, + ); + const { result: useCreateTaskRunConcurrencyLimitResult } = renderHook( + useCreateTaskRunConcurrencyLimit, + { wrapper: createWrapper({ queryClient }) }, + ); + + // ------------ Invoke mutation + act(() => + useCreateTaskRunConcurrencyLimitResult.current.createTaskRunConcurrencyLimit( + MOCK_NEW_DATA, + ), + ); + + // ------------ Assert + await waitFor(() => + expect(useCreateTaskRunConcurrencyLimitResult.current.isSuccess).toBe( + true, + ), + ); + expect(useListTaskRunConcurrencyLimitsResult.current.data).toHaveLength(2); + const newLimit = useListTaskRunConcurrencyLimitsResult.current.data?.find( + (limit) => limit.id === MOCK_NEW_DATA_ID, + ); + expect(newLimit).toMatchObject(NEW_LIMIT_DATA); + }); + /** + * Data Management: + * - Asserts reset mutation API is called. + * - Upon resetting active task run mutation API being called, cache is invalidated and asserts cache invalidation APIS are called + */ + it("useCreateTaskRunConcurrencyLimit() invalidates cache and fetches updated value", async () => { + const queryClient = new QueryClient(); + const MOCK_TAG_NAME = "my tag 0"; + + // ------------ Mock API requests after queries are invalidated + const mockData = seedData().map((limit) => + limit.tag === MOCK_TAG_NAME + ? { + ...limit, + active_slots: [], + } + : limit, + ); + + mockFetchListAPI(mockData); + + // ------------ Initialize cache + queryClient.setQueryData(queryKeyFactory.list(filter), seedData()); + + // ------------ Initialize hooks to test + const { result: useListTaskRunConcurrencyLimitsResult } = renderHook( + () => useListTaskRunConcurrencyLimits(filter), + { wrapper: createWrapper({ queryClient }) }, + ); + const { result: useResetTaskRunConcurrencyLimitTagResults } = renderHook( + useResetTaskRunConcurrencyLimitTag, + { wrapper: createWrapper({ queryClient }) }, + ); + + // ------------ Invoke mutation + act(() => + useResetTaskRunConcurrencyLimitTagResults.current.resetTaskRunConcurrencyLimitTag( + MOCK_TAG_NAME, + ), + ); + + // ------------ Assert + await waitFor(() => + expect(useResetTaskRunConcurrencyLimitTagResults.current.isSuccess).toBe( + true, + ), + ); + const limit = useListTaskRunConcurrencyLimitsResult.current.data?.find( + (limit) => limit.tag === MOCK_TAG_NAME, + ); + expect(limit?.active_slots).toHaveLength(0); + }); +}); diff --git a/ui-v2/src/hooks/task-run-concurrency-limits.ts b/ui-v2/src/hooks/task-run-concurrency-limits.ts new file mode 100644 index 000000000000..6a6d51800bf4 --- /dev/null +++ b/ui-v2/src/hooks/task-run-concurrency-limits.ts @@ -0,0 +1,206 @@ +import type { components } from "@/api/prefect"; +import { getQueryService } from "@/api/service"; +import { + queryOptions, + useMutation, + useQueryClient, + useSuspenseQuery, +} from "@tanstack/react-query"; + +export type TaskRunConcurrencyLimit = components["schemas"]["ConcurrencyLimit"]; +export type TaskRunConcurrencyLimitsFilter = + components["schemas"]["Body_read_concurrency_limits_concurrency_limits_filter_post"]; + +/** + * ``` + * 🏗️ Task run concurrency limits queries construction 👷 + * all => ['task-run-concurrency-limits'] // key to match ['task-run-concurrency-limits', ... + * list => ['task-run-concurrency-limits', 'list'] // key to match ['task-run-concurrency-limits', 'list', ... + * ['task-run-concurrency-limits', 'list', { ...filter1 }] + * ['task-run-concurrency-limits', 'list', { ...filter2 }] + * details => ['task-run-concurrency-limits', 'details'] // key to match ['task-run-concurrency-limits', 'details', ... + * ['task-run-concurrency-limits', 'details', id1] + * ['task-run-concurrency-limits', 'details', id2] + * ``` + * */ +export const queryKeyFactory = { + all: () => ["task-run-concurrency-limits"] as const, + lists: () => [...queryKeyFactory.all(), "list"] as const, + list: (filter: TaskRunConcurrencyLimitsFilter) => + [...queryKeyFactory.lists(), filter] as const, + details: () => [...queryKeyFactory.all(), "details"] as const, + detail: (id: string) => [...queryKeyFactory.details(), id] as const, +}; + +// ----- 🔑 Queries 🗄️ +// ---------------------------- +export const buildListTaskRunConcurrencyLimitsQuery = ( + filter: TaskRunConcurrencyLimitsFilter = { offset: 0 }, +) => + queryOptions({ + queryKey: queryKeyFactory.list(filter), + queryFn: async () => { + const res = await getQueryService().POST("/concurrency_limits/filter", { + body: filter, + }); + return res.data ?? []; + }, + refetchInterval: 30_000, + }); + +export const buildDetailTaskRunConcurrencyLimitsQuery = (id: string) => + queryOptions({ + queryKey: queryKeyFactory.detail(id), + queryFn: async () => { + const res = await getQueryService().GET("/concurrency_limits/{id}", { + params: { path: { id } }, + }); + return res.data as TaskRunConcurrencyLimit; // Expecting data to be truthy; + }, + }); + +/** + * + * @param filter + * @returns list of task run concurrency limits as a SuspenseQueryResult object + */ +export const useListTaskRunConcurrencyLimits = ( + filter: TaskRunConcurrencyLimitsFilter = { offset: 0 }, +) => useSuspenseQuery(buildListTaskRunConcurrencyLimitsQuery(filter)); + +/** + * + * @returns details of task run concurrency limits as a SuspenseQueryResult object + */ +export const useGetTaskRunConcurrencyLimit = (id: string) => + useSuspenseQuery(buildDetailTaskRunConcurrencyLimitsQuery(id)); + +// ----- ✍🏼 Mutations 🗄️ +// ---------------------------- + +/** + * Hook for deleting a task run concurrency limit + * + * @returns Mutation object for deleting a task run concurrency limit with loading/error states and trigger function + * + * @example + * ```ts + * const { deleteTaskRunConcurrencyLimit } = useDeleteTaskRunConcurrencyLimit(); + * + * // Delete a taskRun concurrency limit by id or name + * deleteTaskRunConcurrencyLimit('id-to-delete', { + * onSuccess: () => { + * // Handle successful deletion + * }, + * onError: (error) => { + * console.error('Failed to delete task run concurrency limit:', error); + * } + * }); + * ``` + */ +export const useDeleteTaskRunConcurrencyLimit = () => { + const queryClient = useQueryClient(); + const { mutate: deleteTaskRunConcurrencyLimit, ...rest } = useMutation({ + mutationFn: (id: string) => + getQueryService().DELETE("/concurrency_limits/{id}", { + params: { path: { id } }, + }), + onSuccess: () => { + // After a successful deletion, invalidate the listing queries only to refetch + return queryClient.invalidateQueries({ + queryKey: queryKeyFactory.lists(), + }); + }, + }); + return { + deleteTaskRunConcurrencyLimit, + ...rest, + }; +}; + +/** + * Hook for creating a new task run concurrency limit + * + * @returns Mutation object for creating a task run concurrency limit with loading/error states and trigger function + * + * @example + * ```ts + * const { createTaskRunConcurrencyLimit, isLoading } = useCreateTaskRunConcurrencyLimit(); + * + * // Create a new task run concurrency limit + * createTaskRunConcurrencyLimit({ + * tag: "my tag" + * concurrency_limit: 9000 + * }, { + * onSuccess: () => { + * // Handle successful creation + * console.log('Task Run concurrency limit created successfully'); + * }, + * onError: (error) => { + * // Handle error + * console.error('Failed to create task run concurrency limit:', error); + * } + * }); + * ``` + */ +export const useCreateTaskRunConcurrencyLimit = () => { + const queryClient = useQueryClient(); + const { mutate: createTaskRunConcurrencyLimit, ...rest } = useMutation({ + mutationFn: (body: components["schemas"]["ConcurrencyLimitCreate"]) => + getQueryService().POST("/concurrency_limits/", { + body, + }), + onSuccess: () => { + // After a successful creation, invalidate the listing queries only to refetch + return queryClient.invalidateQueries({ + queryKey: queryKeyFactory.lists(), + }); + }, + }); + return { + createTaskRunConcurrencyLimit, + ...rest, + }; +}; + +/** + * Hook for resetting a concurrency limit's active task runs based on the tag name + * + * @returns Mutation object for resetting a task run concurrency limit with loading/error states and trigger function + * + * @example + * ```ts + * const { resetTaskRunConcurrencyLimitTag, isLoading } = useResetTaskRunConcurrencyLimitTag(); + * + * // Create a new task run concurrency limit + * resetTaskRunConcurrencyLimitTag('my-tag', { + * onSuccess: () => { + * // Handle successful creation + * console.log('Task Run concurrency limit tag reset successfully'); + * }, + * onError: (error) => { + * // Handle error + * console.error('Failed to reset task run concurrency limit', error); + * } + * }); + * ``` + */ +export const useResetTaskRunConcurrencyLimitTag = () => { + const queryClient = useQueryClient(); + const { mutate: resetTaskRunConcurrencyLimitTag, ...rest } = useMutation({ + mutationFn: (tag: string) => + getQueryService().POST("/concurrency_limits/tag/{tag}/reset", { + params: { path: { tag } }, + }), + onSuccess: () => { + // After a successful reset, invalidate all to get an updated list and details list + return queryClient.invalidateQueries({ + queryKey: queryKeyFactory.all(), + }); + }, + }); + return { + resetTaskRunConcurrencyLimitTag, + ...rest, + }; +}; diff --git a/ui-v2/src/routes/concurrency-limits.tsx b/ui-v2/src/routes/concurrency-limits.tsx index 6fc4f104e43c..dd130e853cb3 100644 --- a/ui-v2/src/routes/concurrency-limits.tsx +++ b/ui-v2/src/routes/concurrency-limits.tsx @@ -1,5 +1,6 @@ import { ConcurrencyPage } from "@/components/concurrency/concurrency-page"; -import { useListGlobalConcurrencyLimits } from "@/hooks/global-concurrency-limits"; +import { buildListGlobalConcurrencyLimitsQuery } from "@/hooks/global-concurrency-limits"; +import { buildListTaskRunConcurrencyLimitsQuery } from "@/hooks/task-run-concurrency-limits"; import { createFileRoute } from "@tanstack/react-router"; import { zodSearchValidator } from "@tanstack/router-zod-adapter"; import { z } from "zod"; @@ -9,6 +10,7 @@ import { z } from "zod"; * @property {'global' | 'task-run'} tab used designate which tab view to display */ const searchParams = z.object({ + search: z.string().optional(), tab: z.enum(["global", "task-run"]).default("global"), }); @@ -18,5 +20,13 @@ export const Route = createFileRoute("/concurrency-limits")({ validateSearch: zodSearchValidator(searchParams), component: ConcurrencyPage, wrapInSuspense: true, - loader: useListGlobalConcurrencyLimits.loader, + loader: ({ context }) => + Promise.all([ + context.queryClient.ensureQueryData( + buildListGlobalConcurrencyLimitsQuery(), + ), + context.queryClient.ensureQueryData( + buildListTaskRunConcurrencyLimitsQuery(), + ), + ]), }); diff --git a/ui-v2/tests/utils/handlers.ts b/ui-v2/tests/utils/handlers.ts index bf199395ac5c..31184c4a83ab 100644 --- a/ui-v2/tests/utils/handlers.ts +++ b/ui-v2/tests/utils/handlers.ts @@ -21,6 +21,21 @@ const globalConcurrencyLimitsHandlers = [ ), ]; +const taskRunConcurrencyLimitsHandlers = [ + http.post("http://localhost:4200/api/concurrency_limits/filter", () => { + return HttpResponse.json([]); + }), + http.post( + "http://localhost:4200/api/concurrency_limits/tag/:tag/reset", + () => { + return HttpResponse.json({ status: 200 }); + }, + ), + http.delete("http://localhost:4200/api/concurrency_limits/:id", () => { + return HttpResponse.json({ status: 204 }); + }), +]; + const variablesHandlers = [ http.post("http://localhost:4200/api/variables/", () => { return HttpResponse.json({ status: "success" }, { status: 201 }); @@ -62,5 +77,6 @@ export const handlers = [ return HttpResponse.json(1); }), ...globalConcurrencyLimitsHandlers, + ...taskRunConcurrencyLimitsHandlers, ...variablesHandlers, ];