diff --git a/ui-v2/src/hooks/automations.test.ts b/ui-v2/src/hooks/automations.test.ts index ef567d4b874f7..efaa5760a68f9 100644 --- a/ui-v2/src/hooks/automations.test.ts +++ b/ui-v2/src/hooks/automations.test.ts @@ -1,5 +1,5 @@ -import { useSuspenseQuery } from "@tanstack/react-query"; -import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, useSuspenseQuery } 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"; @@ -10,12 +10,16 @@ import { type Automation, buildGetAutomationQuery, buildListAutomationsQuery, + queryKeyFactory, + useCreateAutomation, + useDeleteAutomation, + useUpdateAutomation, } from "./automations"; -describe("automations queries", () => { +describe("automations queries and mutations", () => { const seedAutomationsData = () => [ - createFakeAutomation(), - createFakeAutomation(), + createFakeAutomation({ id: "0" }), + createFakeAutomation({ id: "1" }), ]; const mockFetchListAutomationsAPI = (automations: Array) => { @@ -35,6 +39,7 @@ describe("automations queries", () => { }; const filter = { sort: "CREATED_DESC", offset: 0 } as const; + it("is stores automation list data", async () => { // ------------ Mock API requests when cache is empty const mockList = seedAutomationsData(); @@ -67,4 +72,158 @@ describe("automations queries", () => { await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(result.current.data).toEqual(mockData); }); + + it("useDeleteAutomation is able to call delete API and fetches updated cache", async () => { + const ID_TO_DELETE = "0"; + const queryClient = new QueryClient(); + + // ------------ Mock API requests after queries are invalidated + const mockData = seedAutomationsData().filter( + (automation) => automation.id !== ID_TO_DELETE, + ); + mockFetchListAutomationsAPI(mockData); + + // ------------ Initialize cache + queryClient.setQueryData( + queryKeyFactory.list(filter), + seedAutomationsData(), + ); + + // ------------ Initialize hooks to test + const { result: useDeleteAutomationResult } = renderHook( + useDeleteAutomation, + { wrapper: createWrapper({ queryClient }) }, + ); + + const { result: useListAutomationsResult } = renderHook( + () => useSuspenseQuery(buildListAutomationsQuery()), + { wrapper: createWrapper({ queryClient }) }, + ); + + // ------------ Invoke mutation + act(() => useDeleteAutomationResult.current.deleteAutomation(ID_TO_DELETE)); + + // ------------ Assert + await waitFor(() => + expect(useDeleteAutomationResult.current.isSuccess).toBe(true), + ); + expect(useListAutomationsResult.current.data).toHaveLength(1); + }); + + it("useCreateAutomation() invalidates cache and fetches updated value", async () => { + const queryClient = new QueryClient(); + const MOCK_NEW_DATA_ID = "2"; + const NEW_AUTOMATION_DATA = createFakeAutomation({ + id: MOCK_NEW_DATA_ID, + created: "2021-01-01T00:00:00Z", + updated: "2021-01-01T00:00:00Z", + }); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { id, created, updated, ...CREATE_AUTOMATION_PAYLOAD } = + NEW_AUTOMATION_DATA; + + // ------------ Mock API requests after queries are invalidated + const mockData = [...seedAutomationsData(), NEW_AUTOMATION_DATA]; + mockFetchListAutomationsAPI(mockData); + + // ------------ Initialize cache + queryClient.setQueryData( + queryKeyFactory.list(filter), + seedAutomationsData(), + ); + + // ------------ Initialize hooks to test + const { result: useCreateAutomationResult } = renderHook( + useCreateAutomation, + { wrapper: createWrapper({ queryClient }) }, + ); + + const { result: useListAutomationsResult } = renderHook( + () => useSuspenseQuery(buildListAutomationsQuery(filter)), + { wrapper: createWrapper({ queryClient }) }, + ); + + // ------------ Invoke mutation + act(() => + useCreateAutomationResult.current.createAutomation( + CREATE_AUTOMATION_PAYLOAD, + ), + ); + + // ------------ Assert + await waitFor(() => + expect(useCreateAutomationResult.current.isSuccess).toBe(true), + ); + + expect(useListAutomationsResult.current.data).toHaveLength(3); + + const newAutomation = useListAutomationsResult.current.data?.find( + (automation) => automation.id === MOCK_NEW_DATA_ID, + ); + expect(newAutomation).toMatchObject(NEW_AUTOMATION_DATA); + }); + + it("useUpdateAutomation() invalidates cache and fetches updated value", async () => { + const queryClient = new QueryClient(); + const MOCK_INITIAL_DATA = seedAutomationsData(); + const MOCK_EDIT_ID = "1"; + const EDITED_AUTOMATION = MOCK_INITIAL_DATA.find( + (automation) => automation.id === MOCK_EDIT_ID, + ); + if (!EDITED_AUTOMATION) { + throw new Error("Expected 'EDITED_AUTOMATION'"); + } + EDITED_AUTOMATION.name = "Edited automation name"; + + const MOCK_EDITED_DATA = MOCK_INITIAL_DATA.map((automation) => + automation.id === MOCK_EDIT_ID ? EDITED_AUTOMATION : automation, + ); + + // ------------ Mock API requests after queries are invalidated + mockFetchListAutomationsAPI(MOCK_EDITED_DATA); + + // ------------ Initialize cache + queryClient.setQueryData( + queryKeyFactory.list(filter), + seedAutomationsData(), + ); + + // ------------ Initialize hooks to test + const { result: useUpdateAutomationResult } = renderHook( + useUpdateAutomation, + { + wrapper: createWrapper({ queryClient }), + }, + ); + + const { result: useListAutomationsResult } = renderHook( + () => useSuspenseQuery(buildListAutomationsQuery(filter)), + { wrapper: createWrapper({ queryClient }) }, + ); + + // ------------ Invoke mutation + const { id, actions, name, description, enabled, trigger } = + EDITED_AUTOMATION; + act(() => + useUpdateAutomationResult.current.updateAutomation({ + id, + name, + description, + enabled, + trigger, + actions, + }), + ); + + // ------------ Assert + await waitFor(() => + expect(useUpdateAutomationResult.current.isSuccess).toBe(true), + ); + + const editedAutomation = useListAutomationsResult.current.data?.find( + (automation) => automation.id === MOCK_EDIT_ID, + ); + expect(editedAutomation).toMatchObject(EDITED_AUTOMATION); + }); }); diff --git a/ui-v2/src/hooks/automations.ts b/ui-v2/src/hooks/automations.ts index c2a4a65ee08a3..9789d2a0f96bb 100644 --- a/ui-v2/src/hooks/automations.ts +++ b/ui-v2/src/hooks/automations.ts @@ -1,6 +1,10 @@ import type { components } from "@/api/prefect"; import { getQueryService } from "@/api/service"; -import { queryOptions } from "@tanstack/react-query"; +import { + queryOptions, + useMutation, + useQueryClient, +} from "@tanstack/react-query"; export type Automation = components["schemas"]["Automation"]; export type AutomationsFilter = @@ -58,3 +62,130 @@ export const buildGetAutomationQuery = (id: string) => return res.data; }, }); + +// ----- ✍🏼 Mutations 🗄️ +// ---------------------------- + +/** + * Hook for deleting an automation + * + * @returns Mutation object for deleting an automation with loading/error states and trigger function + * + * @example + * ```ts + * const { deleteAutomation } = useDeleteAutomation(); + * + * // Delete an automation by id + * deleteAutomation('id-to-delete', { + * onSuccess: () => { + * // Handle successful deletion + * }, + * onError: (error) => { + * console.error('Failed to delete automation:', error); + * } + * }); + * ``` + */ +export const useDeleteAutomation = () => { + const queryClient = useQueryClient(); + const { mutate: deleteAutomation, ...rest } = useMutation({ + mutationFn: (id: string) => + getQueryService().DELETE("/automations/{id}", { + params: { path: { id } }, + }), + onSuccess: () => { + // After a successful deletion, invalidate the listing queries only to refetch + return queryClient.invalidateQueries({ + queryKey: queryKeyFactory.lists(), + }); + }, + }); + return { + deleteAutomation, + ...rest, + }; +}; + +/** + * Hook for creating a new automation + * + * @returns Mutation object for creating an automation with loading/error states and trigger function + * + * @example + * ```ts + * const { createAutomation, isLoading } = useCreateAutomation(); + * + * createAutomation(newAutomation, { + * onSuccess: () => { + * // Handle successful creation + * console.log('Automation created successfully'); + * }, + * onError: (error) => { + * // Handle error + * console.error('Failed to create automation:', error); + * } + * }); + * ``` + */ +export const useCreateAutomation = () => { + const queryClient = useQueryClient(); + const { mutate: createAutomation, ...rest } = useMutation({ + mutationFn: (body: components["schemas"]["AutomationCreate"]) => + getQueryService().POST("/automations/", { body }), + onSuccess: () => { + // After a successful creation, invalidate the listing queries only to refetch + return queryClient.invalidateQueries({ + queryKey: queryKeyFactory.lists(), + }); + }, + }); + return { + createAutomation, + ...rest, + }; +}; + +/** + * Hook for editing an automation based on ID + * + * @returns Mutation object for editing an automation with loading/error states and trigger function + * + * @example + * ```ts + * const { updateAutomation, isLoading } = useUpdateAutomation(); + * + * // Create a new task run concurrency limit + * updateAutomation('my-id', { + * onSuccess: () => { + * console.log('Automation edited successfully'); + * }, + * onError: (error) => { + * // Handle error + * console.error('Failed to edit automation', error); + * } + * }); + * ``` + */ +export const useUpdateAutomation = () => { + const queryClient = useQueryClient(); + const { mutate: updateAutomation, ...rest } = useMutation({ + mutationFn: ({ + id, + ...body + }: components["schemas"]["AutomationUpdate"] & { id: string }) => + getQueryService().PATCH("/automations/{id}", { + body, + params: { path: { id } }, + }), + onSuccess: () => { + // After a successful reset, invalidate all to get an updated list and details list + return queryClient.invalidateQueries({ + queryKey: queryKeyFactory.all(), + }); + }, + }); + return { + updateAutomation, + ...rest, + }; +}; diff --git a/ui-v2/tests/utils/handlers.ts b/ui-v2/tests/utils/handlers.ts index 873366a1ca03c..443c660687424 100644 --- a/ui-v2/tests/utils/handlers.ts +++ b/ui-v2/tests/utils/handlers.ts @@ -12,7 +12,7 @@ const automationsHandlers = [ http.patch("http://localhost:4200/api/automations/:id", () => { return new HttpResponse(null, { status: 204 }); }), - http.delete("http://localhost:4200/api/api/:id", () => { + http.delete("http://localhost:4200/api/automations/:id", () => { return HttpResponse.json({ status: 204 }); }), ];