Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[UI-v2] feat: finish flows page #17108

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 83 additions & 3 deletions ui-v2/src/api/flows/flows.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import type { components } from "@/api/prefect";
import { createFakeFlow } from "@/mocks";
import { QueryClient, useSuspenseQuery } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, useQuery, useSuspenseQuery } from "@tanstack/react-query";
import { act, renderHook, waitFor } from "@testing-library/react";
import { buildApiUrl, createWrapper, server } from "@tests/utils";
import { http, HttpResponse } from "msw";
import { describe, expect, it } from "vitest";
import { buildFLowDetailsQuery, buildListFlowsQuery } from ".";
import {
buildCountFlowsFilteredQuery,
buildFLowDetailsQuery,
buildListFlowsQuery,
queryKeyFactory,
useDeleteFlowById,
} from ".";

type Flow = components["schemas"]["Flow"];

Expand All @@ -26,6 +32,14 @@ describe("flows api", () => {
);
};

const mockFetchCountFlowsAPI = (count: number) => {
server.use(
http.post(buildApiUrl("/flows/count"), () => {
return HttpResponse.json(count);
}),
);
};

describe("flowsQueryParams", () => {
it("fetches paginated flows with default parameters", async () => {
const flow = createFakeFlow();
Expand Down Expand Up @@ -81,4 +95,70 @@ describe("flows api", () => {
expect(result.current.data).toEqual(flow);
});
});

describe("buildCountFlowsFilteredQuery", () => {
it("fetches count of filtered flows from the API", async () => {
const flow = createFakeFlow();
mockFetchCountFlowsAPI(1);

const queryClient = new QueryClient();
const { result } = renderHook(
() =>
useSuspenseQuery(
buildCountFlowsFilteredQuery({
flows: {
operator: "and_",
name: { like_: flow.name },
},
offset: 0,
sort: "CREATED_DESC",
}),
),
{ wrapper: createWrapper({ queryClient }) },
);

await waitFor(() => expect(result.current.isSuccess).toBe(true));

expect(result.current.data).toEqual(1);
});
});

describe("useDeleteFlowById", () => {
const mockFlow = createFakeFlow();

it("invalidates cache and fetches updated value", async () => {
const queryClient = new QueryClient();
// Original cached value
queryClient.setQueryData(queryKeyFactory.all(), [mockFlow]);

// Updated fetch and cached value
mockFetchFlowsAPI([mockFlow]);

// Delete flow
server.use(
http.delete(buildApiUrl("/flows/:id"), () => {
return HttpResponse.json({});
}),
);

const { result: useListFLowsResult } = renderHook(
() => useQuery(buildListFlowsQuery()),
{ wrapper: createWrapper({ queryClient }) },
);

const { result: useDeleteFlowByIdResult } = renderHook(
useDeleteFlowById,
{ wrapper: createWrapper({ queryClient }) },
);

act(() => useDeleteFlowByIdResult.current.deleteFlow(mockFlow.id));

mockFetchFlowsAPI([]);

await waitFor(() => {
return expect(useDeleteFlowByIdResult.current.isSuccess).toBe(true);
});
expect(useListFLowsResult.current.data).toHaveLength(0);
});
});
});
72 changes: 71 additions & 1 deletion ui-v2/src/api/flows/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { components } from "@/api/prefect";
import { getQueryService } from "@/api/service";
import { keepPreviousData, queryOptions } from "@tanstack/react-query";
import {
keepPreviousData,
queryOptions,
useMutation,
useQueryClient,
} from "@tanstack/react-query";

export type Flow = components["schemas"]["Flow"];
export type FlowsFilter =
Expand Down Expand Up @@ -93,3 +98,68 @@ export const buildFLowDetailsQuery = (id: string) =>
return res.data;
},
});

/**
* Builds a query configuration for counting filtered flows
* @param filter - Filter parameters for the flows query
* @returns Query configuration object with:
* - queryKey: Unique key for caching
* - queryFn: Function to fetch the filtered flows
* - staleTime: Time in ms before data is considered stale
* @example
* ```ts
* const query = buildCountFlowsFilteredQuery({
* flows: {
* operator: "and_",
* name: { like_: "my-flow" }
* }
* });
* const { data } = await queryClient.fetchQuery(query);
* ```
* */
export const buildCountFlowsFilteredQuery = (filter: FlowsFilter) =>
queryOptions({
queryKey: queryKeyFactory.list(filter),
queryFn: async () => {
const result = await getQueryService().POST("/flows/count", {
body: filter,
});
return result.data ?? 0;
},
});

/**
* Hook for deleting a flow
*
* @returns Mutation object for deleting a flow with loading/error states and trigger function
*
* @example
* ```ts
* const { deleteDeploymentSchedule } = useDeleteDeploymentSchedule();
*
* deleteDeploymentSchedule({deployment_id, schedule_id, ...body}, {
* onSuccess: () => {
* // Handle successful update
* console.log('Deployment schedule deleted successfully');
* },
* onError: (error) => {
* // Handle error
* console.error('Failed to delete deployment schedule:', error);
* }
* });
* ```
*/
export const useDeleteFlowById = () => {
const queryClient = useQueryClient();

const { mutate: deleteFlow, ...rest } = useMutation({
mutationFn: (id: string) =>
getQueryService().DELETE("/flows/{id}", {
params: { path: { id } },
}),
onSettled: () =>
queryClient.invalidateQueries({ queryKey: queryKeyFactory.all() }),
});

return { deleteFlow, ...rest };
};
107 changes: 76 additions & 31 deletions ui-v2/src/components/flows/cells.tsx
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO if flowRun[0] is expected, just throw an assert saying its expected. This should help resolve weird TS issues, and if the assert does get thrown, then it at least signals that our assumptions are wrong

Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ import { Icon } from "@/components/ui/icons";
import { cn } from "@/lib/utils";
import { useQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { format, parseISO } from "date-fns";

import { useDeleteFlowById } from "@/api/flows";
import { formatDate } from "@/utils/date";
import { Typography } from "../ui/typography";
import {
deploymentsCountQueryParams,
getLatestFlowRunsQueryParams,
Expand All @@ -22,47 +24,75 @@ import {

type Flow = components["schemas"]["Flow"];

export const FlowsTableHeaderCell = ({ content }: { content: string }) => (
<Typography variant="bodySmall" className="font-bold text-black m-2">
{content}
</Typography>
);
export const FlowName = ({ row }: { row: { original: Flow } }) => {
if (!row.original.id) return null;

return (
<div>
<div className="m-2">
<Link
to="/flows/flow/$id"
params={{ id: row.original.id }}
className="text-primary hover:underline cursor-pointer"
className="text-blue-700 hover:underline cursor-pointer"
>
{row.original.name}
</Link>
<div className="text-sm text-muted-foreground">
<Typography
variant="bodySmall"
className="text-sm text-muted-foreground"
fontFamily="mono"
>
Created{" "}
{row.original?.created &&
format(parseISO(row.original.created), "yyyy-MM-dd")}
</div>
{row.original?.created && formatDate(row.original.created, "dateTime")}
</Typography>
</div>
);
};

export const FlowLastRun = ({ row }: { row: { original: Flow } }) => {
const { data } = useQuery(
const { data: flowRun } = useQuery(
getLatestFlowRunsQueryParams(row.original.id || "", 16, {
enabled: !!row.original.id,
}),
);

if (!row.original.id) return null;
return JSON.stringify(data?.[0]?.name);
return (
<Link to="/runs/flow-run/$id" params={{ id: flowRun?.[0]?.id ?? "" }}>
<Typography
variant="bodySmall"
className="text-blue-700 hover:underline p-2"
fontFamily="mono"
>
{flowRun?.[0]?.name}
</Typography>
</Link>
);
};

export const FlowNextRun = ({ row }: { row: { original: Flow } }) => {
const { data } = useQuery(
const { data: flowRun } = useQuery(
getNextFlowRunsQueryParams(row.original.id || "", 16, {
enabled: !!row.original.id,
}),
);

if (!row.original.id) return null;
return JSON.stringify(data?.[0]?.name);
return (
<Link to="/runs/flow-run/$id" params={{ id: flowRun?.[0]?.id ?? "" }}>
<Typography
variant="bodySmall"
className="text-blue-700 hover:underline p-2"
fontFamily="mono"
>
{flowRun?.[0]?.name}
</Typography>
</Link>
);
};

export const FlowDeploymentCount = ({ row }: { row: { original: Flow } }) => {
Expand All @@ -73,34 +103,49 @@ export const FlowDeploymentCount = ({ row }: { row: { original: Flow } }) => {
);
if (!row.original.id) return null;

return data;
return (
<Typography
variant="bodySmall"
className="text-muted-foreground hover:underline p-2"
fontFamily="mono"
>
{data != 0 ? data : "None"}
</Typography>
);
};

export const FlowActionMenu = ({ row }: { row: { original: Flow } }) => {
const id = row.original.id;

const { deleteFlow } = useDeleteFlowById();

if (!id) {
return null;
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<Icon id="MoreVertical" className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => void navigator.clipboard.writeText(id)}
>
Copy ID
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>Delete</DropdownMenuItem>
<DropdownMenuItem>Automate</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex justify-end m-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<Icon id="MoreVertical" className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => void navigator.clipboard.writeText(id)}
>
Copy ID
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => deleteFlow(id)}>
Delete
</DropdownMenuItem>
<DropdownMenuItem>Automate</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
};

Expand Down
Loading