diff --git a/ui-v2/src/hooks/global-concurrency-limits.test.tsx b/ui-v2/src/hooks/global-concurrency-limits.test.tsx index a9675017440c..cd94a8f47ab0 100644 --- a/ui-v2/src/hooks/global-concurrency-limits.test.tsx +++ b/ui-v2/src/hooks/global-concurrency-limits.test.tsx @@ -1,12 +1,15 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { renderHook, waitFor } from "@testing-library/react"; +import { act, renderHook, waitFor } from "@testing-library/react"; import { http, HttpResponse } from "msw"; import { describe, expect, it } from "vitest"; import { type GlobalConcurrencyLimit, - useGetGlobalConcurrencyLimit, + queryKeyFactory, + useCreateGlobalConcurrencyLimit, + useDeleteGlobalConcurrencyLimit, useListGlobalConcurrencyLimits, + useUpdateGlobalConcurrencyLimit, } from "./global-concurrency-limits"; import { server } from "../../tests/mocks/node"; @@ -25,17 +28,6 @@ describe("global concurrency limits hooks", () => { }, ]; - const seedGlobalConcurrencyLimitDetails = () => ({ - id: "0", - created: "2021-01-01T00:00:00Z", - updated: "2021-01-01T00:00:00Z", - active: false, - name: "global concurrency limit 0", - limit: 0, - active_slots: 0, - slot_decay_per_second: 0, - }); - const mockFetchGlobalConcurrencyLimitsAPI = ( globalConcurrencyLimits: Array, ) => { @@ -49,19 +41,6 @@ describe("global concurrency limits hooks", () => { ); }; - const mockFetchGlobalConcurrencyLimitDetailsAPI = ( - globalConcurrencyLimit: GlobalConcurrencyLimit, - ) => { - server.use( - http.get( - "http://localhost:4200/api/v2/concurrency_limits/:id_or_name", - () => { - return HttpResponse.json(globalConcurrencyLimit); - }, - ), - ); - }; - const createQueryWrapper = ({ queryClient = new QueryClient() }) => { const QueryWrapper = ({ children }: { children: React.ReactNode }) => ( {children} @@ -95,21 +74,182 @@ describe("global concurrency limits hooks", () => { /** * Data Management: - * - Asserts global concurrency limit details data is fetched based on the APIs invoked for the hook + * - Asserts global concurrency limit calls delete API and refetches updated list */ - it("is stores details data into the appropriate details query when using useQuery()", async () => { - // ------------ Mock API requests when cache is empty - const mockDetails = seedGlobalConcurrencyLimitDetails(); - mockFetchGlobalConcurrencyLimitDetailsAPI(mockDetails); + it("useDeleteGlobalConcurrencyLimit() 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 = seedGlobalConcurrencyLimits().filter( + (limit) => limit.id !== ID_TO_DELETE, + ); + mockFetchGlobalConcurrencyLimitsAPI(mockData); + + // ------------ Initialize cache + queryClient.setQueryData( + queryKeyFactory.list(filter), + seedGlobalConcurrencyLimits(), + ); // ------------ Initialize hooks to test - const { result } = renderHook( - () => useGetGlobalConcurrencyLimit(mockDetails.id), - { wrapper: createQueryWrapper({}) }, + const { result: useListGlobalConcurrencyLimitsResult } = renderHook( + () => useListGlobalConcurrencyLimits(filter), + { wrapper: createQueryWrapper({ queryClient }) }, + ); + + const { result: useDeleteGlobalConcurrencyLimitResult } = renderHook( + useDeleteGlobalConcurrencyLimit, + { wrapper: createQueryWrapper({ queryClient }) }, + ); + + // ------------ Invoke mutation + act(() => + useDeleteGlobalConcurrencyLimitResult.current.deleteGlobalConcurrencyLimit( + ID_TO_DELETE, + ), ); // ------------ Assert - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(result.current.data).toEqual(mockDetails); + await waitFor(() => + expect(useDeleteGlobalConcurrencyLimitResult.current.isSuccess).toBe( + true, + ), + ); + expect(useListGlobalConcurrencyLimitsResult.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("useCreateGlobalConcurrencyLimit() invalidates cache and fetches updated value", async () => { + const queryClient = new QueryClient(); + const MOCK_NEW_LIMIT_ID = "1"; + const MOCK_NEW_LIMIT = { + active: true, + active_slots: 0, + denied_slots: 0, + limit: 0, + name: "global concurrency limit 1", + slot_decay_per_second: 0, + }; + + // ------------ Mock API requests after queries are invalidated + const NEW_LIMIT_DATA = { + ...MOCK_NEW_LIMIT, + id: MOCK_NEW_LIMIT_ID, + created: "2021-01-01T00:00:00Z", + updated: "2021-01-01T00:00:00Z", + active_slots: 0, + slot_decay_per_second: 0, + }; + + const mockData = [...seedGlobalConcurrencyLimits(), NEW_LIMIT_DATA]; + mockFetchGlobalConcurrencyLimitsAPI(mockData); + + // ------------ Initialize cache + queryClient.setQueryData( + queryKeyFactory.list(filter), + seedGlobalConcurrencyLimits(), + ); + + // ------------ Initialize hooks to test + const { result: useListGlobalConcurrencyLimitsResult } = renderHook( + () => useListGlobalConcurrencyLimits(filter), + { wrapper: createQueryWrapper({ queryClient }) }, + ); + const { result: useCreateGlobalConcurrencyLimitResult } = renderHook( + useCreateGlobalConcurrencyLimit, + { wrapper: createQueryWrapper({ queryClient }) }, + ); + + // ------------ Invoke mutation + act(() => + useCreateGlobalConcurrencyLimitResult.current.createGlobalConcurrencyLimit( + MOCK_NEW_LIMIT, + ), + ); + + // ------------ Assert + await waitFor(() => + expect(useCreateGlobalConcurrencyLimitResult.current.isSuccess).toBe( + true, + ), + ); + expect(useListGlobalConcurrencyLimitsResult.current.data).toHaveLength(2); + const newLimit = useListGlobalConcurrencyLimitsResult.current.data?.find( + (limit) => limit.id === MOCK_NEW_LIMIT_ID, + ); + expect(newLimit).toMatchObject(NEW_LIMIT_DATA); + }); + + /** + * Data Management: + * - Asserts update mutation API is called. + * - Upon update mutation API being called, cache invalidates global concurrency limit details cache + */ + it("useUpdateGlobalConcurrencyLimit() invalidates cache and fetches updated value", async () => { + const queryClient = new QueryClient(); + const MOCK_UPDATE_LIMIT_ID = "0"; + const UPDATED_LIMIT_BODY = { + active: true, + active_slots: 0, + denied_slots: 0, + limit: 0, + name: "global concurrency limit updated", + slot_decay_per_second: 0, + }; + const UPDATED_LIMIT = { + ...UPDATED_LIMIT_BODY, + id: MOCK_UPDATE_LIMIT_ID, + }; + + // ------------ Mock API requests after queries are invalidated + const mockData = seedGlobalConcurrencyLimits().map((limit) => + limit.id === MOCK_UPDATE_LIMIT_ID ? UPDATED_LIMIT : limit, + ); + mockFetchGlobalConcurrencyLimitsAPI(mockData); + + // ------------ Initialize cache + + queryClient.setQueryData( + queryKeyFactory.list(filter), + seedGlobalConcurrencyLimits(), + ); + + // ------------ Initialize hooks to test + const { result: useListGlobalConcurrencyLimitsResult } = renderHook( + () => useListGlobalConcurrencyLimits(filter), + { wrapper: createQueryWrapper({ queryClient }) }, + ); + + const { result: useUpdateGlobalConcurrencyLimitResult } = renderHook( + useUpdateGlobalConcurrencyLimit, + { wrapper: createQueryWrapper({ queryClient }) }, + ); + + // ------------ Invoke mutation + act(() => + useUpdateGlobalConcurrencyLimitResult.current.updateGlobalConcurrencyLimit( + { + id_or_name: MOCK_UPDATE_LIMIT_ID, + ...UPDATED_LIMIT_BODY, + }, + ), + ); + + // ------------ Assert + await waitFor(() => + expect(useUpdateGlobalConcurrencyLimitResult.current.isSuccess).toBe( + true, + ), + ); + + const limit = useListGlobalConcurrencyLimitsResult.current.data?.find( + (limit) => limit.id === MOCK_UPDATE_LIMIT_ID, + ); + expect(limit).toMatchObject(UPDATED_LIMIT); }); }); diff --git a/ui-v2/src/hooks/global-concurrency-limits.ts b/ui-v2/src/hooks/global-concurrency-limits.ts index 2542f8c0bab7..1fd78bb4fd23 100644 --- a/ui-v2/src/hooks/global-concurrency-limits.ts +++ b/ui-v2/src/hooks/global-concurrency-limits.ts @@ -1,6 +1,11 @@ import type { components } from "@/api/prefect"; import { getQueryService } from "@/api/service"; -import { queryOptions, useQuery } from "@tanstack/react-query"; +import { + queryOptions, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; export type GlobalConcurrencyLimit = components["schemas"]["GlobalConcurrencyLimitResponse"]; @@ -9,24 +14,18 @@ export type GlobalConcurrencyLimitsFilter = /** * ``` - * 🏗️ Variable queries construction 👷 + * 🏗️ Global concurrency limits queries construction 👷 * all => ['global-concurrency-limits'] // key to match ['global-concurrency-limits', ... * list => ['global-concurrency-limits', 'list'] // key to match ['global-concurrency-limits', 'list', ... * ['global-concurrency-limits', 'list', { ...filter1 }] * ['global-concurrency-limits', 'list', { ...filter2 }] - * details => ['global-concurrency-limits', 'details'] // key to match ['global-concurrency-limits', 'details', ...] - * ['global-concurrency-limits', 'details', { ...globalConcurrencyLimit1 }] - * ['global-concurrency-limits', 'details', { ...globalConcurrencyLimit2 }] * ``` * */ -const queryKeyFactory = { +export const queryKeyFactory = { all: () => ["global-concurrency-limits"] as const, lists: () => [...queryKeyFactory.all(), "list"] as const, list: (filter: GlobalConcurrencyLimitsFilter) => [...queryKeyFactory.lists(), filter] as const, - details: () => [...queryKeyFactory.all(), "details"] as const, - detail: (id_or_name: string) => - [...queryKeyFactory.details(), id_or_name] as const, }; // ----- 🔑 Queries 🗄️ @@ -45,18 +44,6 @@ export const buildListGlobalConcurrencyLimitsQuery = ( }, }); -export const buildGetGlobalConcurrencyLimitQuery = (id_or_name: string) => - queryOptions({ - queryKey: queryKeyFactory.detail(id_or_name), - queryFn: async () => { - const res = await getQueryService().GET( - "/v2/concurrency_limits/{id_or_name}", - { params: { path: { id_or_name } } }, - ); - return res.data ?? null; - }, - }); - /** * * @param filter @@ -66,15 +53,144 @@ export const useListGlobalConcurrencyLimits = ( filter: GlobalConcurrencyLimitsFilter, ) => useQuery(buildListGlobalConcurrencyLimitsQuery(filter)); +// ----- ✍🏼 Mutations 🗄️ +// ---------------------------- + /** + * Hook for deleting a global concurrency limit * - * @param id_or_name - * @returns details about the specified global concurrency limit as a QueryResult object + * @returns Mutation object for deleting a global concurrency limit with loading/error states and trigger function + * + * @example + * ```ts + * const { deleteGlobalConcurrencyLimit } = useDeleteGlobalConcurrencyLimit(); + * + * // Delete a global concurrency limit by id or name + * deleteGlobalConcurrencyLimit('id-to-delete', { + * onSuccess: () => { + * // Handle successful deletion + * }, + * onError: (error) => { + * console.error('Failed to delete global concurrency limit:', error); + * } + * }); + * ``` */ -export const useGetGlobalConcurrencyLimit = (id_or_name: string) => - useQuery(buildGetGlobalConcurrencyLimitQuery(id_or_name)); +export const useDeleteGlobalConcurrencyLimit = () => { + const queryClient = useQueryClient(); + const { mutate: deleteGlobalConcurrencyLimit, ...rest } = useMutation({ + mutationFn: (id_or_name: string) => + getQueryService().DELETE("/v2/concurrency_limits/{id_or_name}", { + params: { path: { id_or_name } }, + }), + onSuccess: () => { + // After a successful deletion, invalidate the listing queries only to refetch + return queryClient.invalidateQueries({ + queryKey: queryKeyFactory.lists(), + }); + }, + }); + return { + deleteGlobalConcurrencyLimit, + ...rest, + }; +}; -// ----- ✍🏼 Mutations 🗄️ -// ---------------------------- +/** + * Hook for creating a new global concurrency limit + * + * @returns Mutation object for creating a global concurrency limit with loading/error states and trigger function + * + * @example + * ```ts + * const { createGlobalConcurrencyLimit, isLoading } = useCreateGlobalConcurrencyLimit(); + * + * // Create a new global concurrency limit + * createGlobalConcurrencyLimit({ + * active: true + * limit: 0 + * name: "my limit" + * slot_decay_per_second: 0 + * }, { + * onSuccess: () => { + * // Handle successful creation + * console.log('Global concurrency limit created successfully'); + * }, + * onError: (error) => { + * // Handle error + * console.error('Failed to global concurrency limit:', error); + * } + * }); + * ``` + */ +export const useCreateGlobalConcurrencyLimit = () => { + const queryClient = useQueryClient(); + const { mutate: createGlobalConcurrencyLimit, ...rest } = useMutation({ + mutationFn: (body: components["schemas"]["ConcurrencyLimitV2Create"]) => + getQueryService().POST("/v2/concurrency_limits/", { + body, + }), + onSuccess: () => { + // After a successful creation, invalidate the listing queries only to refetch + return queryClient.invalidateQueries({ + queryKey: queryKeyFactory.lists(), + }); + }, + }); + return { + createGlobalConcurrencyLimit, + ...rest, + }; +}; + +type GlobalConcurrencyLimitUpdateWithId = + components["schemas"]["ConcurrencyLimitV2Update"] & { + id_or_name: string; + }; -// TODO: +/** + * Hook for updating an existing global concurrency limit + * + * @returns Mutation object for updating a global concurrency limit with loading/error states and trigger function + * + * @example + * ```ts + * const { udateGlobalConcurrencyLimit } = useUpdateGlobalConcurrencyLimit(); + * + * // Update an existing global concurrency limit + * updateGlobalConcurrencyLimit({ + * id_or_name: "1", + * active: true + * limit: 0 + * name: "my limit" + * slot_decay_per_second: 0 + * }, { + * onSuccess: () => { + * // Handle successful update + * }, + * onError: (error) => { + * console.error('Failed to update global concurrency limit:', error); + * } + * }); + * ``` + */ +export const useUpdateGlobalConcurrencyLimit = () => { + const queryClient = useQueryClient(); + const { mutate: updateGlobalConcurrencyLimit, ...rest } = useMutation({ + mutationFn: ({ id_or_name, ...body }: GlobalConcurrencyLimitUpdateWithId) => + getQueryService().PATCH("/v2/concurrency_limits/{id_or_name}", { + body, + params: { path: { id_or_name } }, + }), + onSuccess: () => { + // After a successful creation, invalidate lists queries + return queryClient.invalidateQueries({ + queryKey: queryKeyFactory.lists(), + }); + }, + }); + return { + updateGlobalConcurrencyLimit, + ...rest, + }; +}; diff --git a/ui-v2/tests/mocks/handlers.ts b/ui-v2/tests/mocks/handlers.ts index d649a6da6aac..bf199395ac5c 100644 --- a/ui-v2/tests/mocks/handlers.ts +++ b/ui-v2/tests/mocks/handlers.ts @@ -1,5 +1,26 @@ import { http, HttpResponse } from "msw"; +const globalConcurrencyLimitsHandlers = [ + http.post("http://localhost:4200/api/v2/concurrency_limits/filter", () => { + return HttpResponse.json([]); + }), + http.post("http://localhost:4200/api/v2/concurrency_limits/", () => { + return HttpResponse.json({ status: "success" }, { status: 201 }); + }), + http.patch( + "http://localhost:4200/api/v2/concurrency_limits/:id_or_name", + () => { + return new HttpResponse(null, { status: 204 }); + }, + ), + http.delete( + "http://localhost:4200/api/v2/concurrency_limits/:id_or_name", + () => { + return HttpResponse.json({ status: 204 }); + }, + ), +]; + const variablesHandlers = [ http.post("http://localhost:4200/api/variables/", () => { return HttpResponse.json({ status: "success" }, { status: 201 }); @@ -40,5 +61,6 @@ export const handlers = [ http.post("http://localhost:4200/api/deployments/count", () => { return HttpResponse.json(1); }), + ...globalConcurrencyLimitsHandlers, ...variablesHandlers, ];