diff --git a/ui-v2/src/hooks/automations.test.ts b/ui-v2/src/hooks/automations.test.ts new file mode 100644 index 000000000000..ef567d4b874f --- /dev/null +++ b/ui-v2/src/hooks/automations.test.ts @@ -0,0 +1,70 @@ +import { useSuspenseQuery } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import { createWrapper, server } from "@tests/utils"; +import { http, HttpResponse } from "msw"; +import { describe, expect, it } from "vitest"; + +import { createFakeAutomation } from "@/mocks"; + +import { + type Automation, + buildGetAutomationQuery, + buildListAutomationsQuery, +} from "./automations"; + +describe("automations queries", () => { + const seedAutomationsData = () => [ + createFakeAutomation(), + createFakeAutomation(), + ]; + + const mockFetchListAutomationsAPI = (automations: Array) => { + server.use( + http.post("http://localhost:4200/api/automations/filter", () => { + return HttpResponse.json(automations); + }), + ); + }; + + const mockFetchGetAutomationsAPI = (automation: Automation) => { + server.use( + http.get("http://localhost:4200/api/automations/:id", () => { + return HttpResponse.json(automation); + }), + ); + }; + + 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(); + mockFetchListAutomationsAPI(mockList); + + // ------------ Initialize hooks to test + const { result } = renderHook( + () => useSuspenseQuery(buildListAutomationsQuery(filter)), + { wrapper: createWrapper() }, + ); + + // ------------ Assert + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual(mockList); + }); + + it("is retrieves single automation data", async () => { + // ------------ Mock API requests when cache is empty + const MOCK_ID = "0"; + const mockData = createFakeAutomation({ id: MOCK_ID }); + mockFetchGetAutomationsAPI(mockData); + + // ------------ Initialize hooks to test + const { result } = renderHook( + () => useSuspenseQuery(buildGetAutomationQuery(MOCK_ID)), + { wrapper: createWrapper() }, + ); + + // ------------ Assert + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual(mockData); + }); +}); diff --git a/ui-v2/src/hooks/automations.ts b/ui-v2/src/hooks/automations.ts new file mode 100644 index 000000000000..c2a4a65ee08a --- /dev/null +++ b/ui-v2/src/hooks/automations.ts @@ -0,0 +1,60 @@ +import type { components } from "@/api/prefect"; +import { getQueryService } from "@/api/service"; +import { queryOptions } from "@tanstack/react-query"; + +export type Automation = components["schemas"]["Automation"]; +export type AutomationsFilter = + components["schemas"]["Body_read_automations_automations_filter_post"]; + +/** + * ``` + * 🏗️ Automations queries construction 👷 + * all => ['automations'] // key to match ['automationss', ... + * list => ['automations', 'list'] // key to match ['automations, 'list', ... + * ['automations', 'list', { ...filter1 }] + * ['automations', 'list', { ...filter2 }] + * details => ['automations', 'details'] // key to match ['automations', 'details', ... + * ['automations', 'details', id1] + * ['automations', 'details', id2] + * ``` + * */ +export const queryKeyFactory = { + all: () => ["automations"] as const, + lists: () => [...queryKeyFactory.all(), "list"] as const, + list: (filter: AutomationsFilter) => + [...queryKeyFactory.lists(), filter] as const, + details: () => [...queryKeyFactory.all(), "details"] as const, + detail: (id: string) => [...queryKeyFactory.details(), id] as const, +}; + +// ----- 🔑 Queries 🗄️ +// ---------------------------- +export const buildListAutomationsQuery = ( + filter: AutomationsFilter = { sort: "CREATED_DESC", offset: 0 }, +) => + queryOptions({ + queryKey: queryKeyFactory.list(filter), + queryFn: async () => { + const res = await getQueryService().POST("/automations/filter", { + body: filter, + }); + if (!res.data) { + throw new Error("'data' expected"); + } + return res.data; + }, + }); + +export const buildGetAutomationQuery = (id: string) => + queryOptions({ + queryKey: queryKeyFactory.detail(id), + queryFn: async () => { + const res = await getQueryService().GET("/automations/{id}", { + params: { path: { id } }, + }); + if (!res.data) { + throw new Error("'data' expected"); + } + return res.data; + }, + }); diff --git a/ui-v2/src/mocks/create-fake-automation.ts b/ui-v2/src/mocks/create-fake-automation.ts new file mode 100644 index 000000000000..6bb074cc756f --- /dev/null +++ b/ui-v2/src/mocks/create-fake-automation.ts @@ -0,0 +1,37 @@ +import type { components } from "@/api/prefect"; +import { faker } from "@faker-js/faker"; + +export const createFakeAutomation = ( + overrides?: Partial, +): components["schemas"]["Automation"] => { + return { + name: `${faker.word.adjective()} automation`, + description: `${faker.word.adjective()} ${faker.word.noun()}`, + enabled: faker.datatype.boolean(), + trigger: { + type: "event", + id: faker.string.uuid(), + match: { + "prefect.resource.id": "prefect.deployment.*", + }, + match_related: {}, + after: [], + expect: ["prefect.deployment.not-ready"], + for_each: ["prefect.resource.id"], + posture: "Reactive", + threshold: 1, + within: 0, + }, + actions: [ + { + type: "cancel-flow-run", + }, + ], + actions_on_trigger: [], + actions_on_resolve: [], + id: faker.string.uuid(), + created: faker.date.past().toISOString(), + updated: faker.date.past().toISOString(), + ...overrides, + }; +}; diff --git a/ui-v2/src/mocks/index.ts b/ui-v2/src/mocks/index.ts new file mode 100644 index 000000000000..fbb74af0f6fe --- /dev/null +++ b/ui-v2/src/mocks/index.ts @@ -0,0 +1 @@ +export { createFakeAutomation } from "./create-fake-automation"; diff --git a/ui-v2/src/routes/automations/automation.$id.ts b/ui-v2/src/routes/automations/automation.$id.ts index 2c190c99b260..5a2061b6f909 100644 --- a/ui-v2/src/routes/automations/automation.$id.ts +++ b/ui-v2/src/routes/automations/automation.$id.ts @@ -1,7 +1,12 @@ import { createFileRoute } from "@tanstack/react-router"; +import { buildGetAutomationQuery } from "@/hooks/automations"; + export const Route = createFileRoute("/automations/automation/$id")({ component: RouteComponent, + loader: ({ context, params }) => + context.queryClient.ensureQueryData(buildGetAutomationQuery(params.id)), + wrapInSuspense: true, }); function RouteComponent() { diff --git a/ui-v2/src/routes/automations/index.ts b/ui-v2/src/routes/automations/index.ts index d8cab0c76f89..c7a02d901a31 100644 --- a/ui-v2/src/routes/automations/index.ts +++ b/ui-v2/src/routes/automations/index.ts @@ -1,7 +1,12 @@ +import { buildListAutomationsQuery } from "@/hooks/automations"; import { createFileRoute } from "@tanstack/react-router"; +// nb: Currently there is no filtering or search params used on this page export const Route = createFileRoute("/automations/")({ component: RouteComponent, + loader: ({ context }) => + context.queryClient.ensureQueryData(buildListAutomationsQuery()), + wrapInSuspense: true, }); function RouteComponent() { diff --git a/ui-v2/tests/utils/handlers.ts b/ui-v2/tests/utils/handlers.ts index 31184c4a83ab..873366a1ca03 100644 --- a/ui-v2/tests/utils/handlers.ts +++ b/ui-v2/tests/utils/handlers.ts @@ -1,5 +1,43 @@ import { http, HttpResponse } from "msw"; +const automationsHandlers = [ + http.post("http://localhost:4200/api/automations/filter", () => { + return HttpResponse.json([]); + }), + + http.post("http://localhost:4200/api/automations/", () => { + return HttpResponse.json({ status: "success" }, { status: 201 }); + }), + + http.patch("http://localhost:4200/api/automations/:id", () => { + return new HttpResponse(null, { status: 204 }); + }), + http.delete("http://localhost:4200/api/api/:id", () => { + return HttpResponse.json({ status: 204 }); + }), +]; + +const flowHandlers = [ + http.post("http://localhost:4200/api/flows/paginate", () => { + return HttpResponse.json({ + results: [ + { id: "1", name: "Flow 1", tags: [] }, + { id: "2", name: "Flow 2", tags: [] }, + ], + }); + }), + http.post("http://localhost:4200/api/flow_runs/filter", () => { + return HttpResponse.json([ + { id: "1", name: "Flow 1", tags: [] }, + { id: "2", name: "Flow 2", tags: [] }, + ]); + }), + + http.post("http://localhost:4200/api/deployments/count", () => { + return HttpResponse.json(1); + }), +]; + const globalConcurrencyLimitsHandlers = [ http.post("http://localhost:4200/api/v2/concurrency_limits/filter", () => { return HttpResponse.json([]); @@ -58,24 +96,8 @@ const variablesHandlers = [ ]; export const handlers = [ - http.post("http://localhost:4200/api/flows/paginate", () => { - return HttpResponse.json({ - results: [ - { id: "1", name: "Flow 1", tags: [] }, - { id: "2", name: "Flow 2", tags: [] }, - ], - }); - }), - http.post("http://localhost:4200/api/flow_runs/filter", () => { - return HttpResponse.json([ - { id: "1", name: "Flow 1", tags: [] }, - { id: "2", name: "Flow 2", tags: [] }, - ]); - }), - - http.post("http://localhost:4200/api/deployments/count", () => { - return HttpResponse.json(1); - }), + ...automationsHandlers, + ...flowHandlers, ...globalConcurrencyLimitsHandlers, ...taskRunConcurrencyLimitsHandlers, ...variablesHandlers,