From 1e2acaecc6e6d779a1b88b3b59c0fd9ee067c3c7 Mon Sep 17 00:00:00 2001 From: Devin Villarosa Date: Wed, 1 Jan 2025 17:47:01 -0800 Subject: [PATCH] [UI v2] feat: Start automations data queries --- ui-v2/src/hooks/automations.test.ts | 95 +++++++++++++++++++ ui-v2/src/hooks/automations.ts | 80 ++++++++++++++++ ui-v2/src/mocks/create-fake-automation.ts | 37 ++++++++ ui-v2/src/mocks/index.ts | 1 + .../src/routes/automations/automation.$id.ts | 5 + ui-v2/src/routes/automations/index.ts | 11 +++ 6 files changed, 229 insertions(+) create mode 100644 ui-v2/src/hooks/automations.test.ts create mode 100644 ui-v2/src/hooks/automations.ts create mode 100644 ui-v2/src/mocks/create-fake-automation.ts create mode 100644 ui-v2/src/mocks/index.ts diff --git a/ui-v2/src/hooks/automations.test.ts b/ui-v2/src/hooks/automations.test.ts new file mode 100644 index 0000000000000..a074e4341e19a --- /dev/null +++ b/ui-v2/src/hooks/automations.test.ts @@ -0,0 +1,95 @@ +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, + buildCountAllAutomationsQuery, + 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 mockFetchCountAutomationsAPI = (count: number) => { + server.use( + http.post("http://localhost:4200/api/automations/count", () => { + return HttpResponse.json(count); + }), + ); + }; + + 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); + }); + + it("is retrieves all automations count", async () => { + // ------------ Mock API requests when cache is empty + const mockData = 3; + mockFetchCountAutomationsAPI(mockData); + + // ------------ Initialize hooks to test + const { result } = renderHook( + () => useSuspenseQuery(buildCountAllAutomationsQuery()), + { 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 0000000000000..9c7c8b58e12fd --- /dev/null +++ b/ui-v2/src/hooks/automations.ts @@ -0,0 +1,80 @@ +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"]; +export type AutomationsCountFilter = + 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 }] + * count => ['automations', 'count'] // key to match ['automations, 'count', ... + * ['automations', 'count', 'all-count'] + * 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, + counts: () => [...queryKeyFactory.all(), "count"] as const, + countAll: () => [...queryKeyFactory.lists(), "all-count"] as const, + count: (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 buildCountAllAutomationsQuery = () => + queryOptions({ + queryKey: queryKeyFactory.countAll(), + queryFn: async () => { + const res = await getQueryService().POST("/automations/count"); + 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 0000000000000..6bb074cc756f8 --- /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 0000000000000..fbb74af0f6fee --- /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 2c190c99b2601..5300f04b4edc3 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, + wrapInSuspense: true, + loader: ({ context, params }) => + context.queryClient.ensureQueryData(buildGetAutomationQuery(params.id)), }); function RouteComponent() { diff --git a/ui-v2/src/routes/automations/index.ts b/ui-v2/src/routes/automations/index.ts index d8cab0c76f893..34188084a6fd8 100644 --- a/ui-v2/src/routes/automations/index.ts +++ b/ui-v2/src/routes/automations/index.ts @@ -1,7 +1,18 @@ +import { + buildCountAllAutomationsQuery, + 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, + wrapInSuspense: true, + loader: ({ context }) => + Promise.all([ + context.queryClient.ensureQueryData(buildCountAllAutomationsQuery()), + context.queryClient.ensureQueryData(buildListAutomationsQuery()), + ]), }); function RouteComponent() {