Skip to content

Commit e1b5996

Browse files
Implement FlowRunArtifacts component for flow run details page (#19967)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: [email protected] <[email protected]>
1 parent beef03b commit e1b5996

File tree

4 files changed

+369
-1
lines changed

4 files changed

+369
-1
lines changed
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
import { buildApiUrl } from "@tests/utils/handlers";
3+
import { HttpResponse, http } from "msw";
4+
import { createFakeArtifact, createFakeFlowRun } from "@/mocks";
5+
import { reactQueryDecorator, routerDecorator } from "@/storybook/utils";
6+
import { FlowRunArtifacts } from "./flow-run-artifacts";
7+
8+
const mockFlowRun = createFakeFlowRun({ id: "flow-run-1" });
9+
10+
const mockArtifacts = [
11+
createFakeArtifact({
12+
id: "artifact-1",
13+
key: "flow-table",
14+
type: "table",
15+
description:
16+
"Flow execution dataset with 1,234 rows and 5 columns: id, name, status, created_at, updated_at",
17+
flow_run_id: mockFlowRun.id,
18+
}),
19+
createFakeArtifact({
20+
id: "artifact-2",
21+
key: "flow-markdown",
22+
type: "markdown",
23+
description:
24+
"# Flow Summary\n\nExecution completed successfully with **99.1%** success rate.",
25+
flow_run_id: mockFlowRun.id,
26+
}),
27+
createFakeArtifact({
28+
id: "artifact-3",
29+
key: "flow-progress",
30+
type: "progress",
31+
description: "Flow completion: 95/100 tasks processed (95%)",
32+
flow_run_id: mockFlowRun.id,
33+
}),
34+
];
35+
36+
const meta = {
37+
title: "Components/FlowRuns/FlowRunArtifacts",
38+
component: FlowRunArtifacts,
39+
decorators: [routerDecorator, reactQueryDecorator],
40+
parameters: {
41+
msw: {
42+
handlers: [
43+
http.post(buildApiUrl("/artifacts/filter"), () => {
44+
return HttpResponse.json(mockArtifacts);
45+
}),
46+
],
47+
},
48+
},
49+
} satisfies Meta<typeof FlowRunArtifacts>;
50+
51+
export default meta;
52+
type Story = StoryObj<typeof FlowRunArtifacts>;
53+
54+
export const Default: Story = {
55+
args: {
56+
flowRun: mockFlowRun,
57+
},
58+
};
59+
60+
export const Empty: Story = {
61+
parameters: {
62+
msw: {
63+
handlers: [
64+
http.post(buildApiUrl("/artifacts/filter"), () => {
65+
return HttpResponse.json([]);
66+
}),
67+
],
68+
},
69+
},
70+
args: {
71+
flowRun: createFakeFlowRun({ id: "flow-run-empty" }),
72+
},
73+
};
74+
75+
export const SingleArtifact: Story = {
76+
parameters: {
77+
msw: {
78+
handlers: [
79+
http.post(buildApiUrl("/artifacts/filter"), () => {
80+
return HttpResponse.json([
81+
createFakeArtifact({
82+
id: "single-artifact",
83+
key: "single-table",
84+
type: "table",
85+
description: "Flow activity log with 500 entries",
86+
flow_run_id: "flow-run-single",
87+
}),
88+
]);
89+
}),
90+
],
91+
},
92+
},
93+
args: {
94+
flowRun: createFakeFlowRun({ id: "flow-run-single" }),
95+
},
96+
};
97+
98+
export const ManyArtifacts: Story = {
99+
parameters: {
100+
msw: {
101+
handlers: [
102+
http.post(buildApiUrl("/artifacts/filter"), () => {
103+
const manyFlowRun = createFakeFlowRun({ id: "flow-run-many" });
104+
return HttpResponse.json([
105+
createFakeArtifact({
106+
id: "many-artifact-1",
107+
key: "sales-data",
108+
type: "table",
109+
description:
110+
"Q4 sales data: 10,000 transactions across 5 regions",
111+
flow_run_id: manyFlowRun.id,
112+
}),
113+
createFakeArtifact({
114+
id: "many-artifact-2",
115+
key: "analysis-report",
116+
type: "markdown",
117+
description:
118+
"## Analysis Complete\n\nFound **3 anomalies** in the dataset.",
119+
flow_run_id: manyFlowRun.id,
120+
}),
121+
createFakeArtifact({
122+
id: "many-artifact-3",
123+
key: "etl-progress",
124+
type: "progress",
125+
description: "ETL pipeline: 450/500 records transformed (90%)",
126+
flow_run_id: manyFlowRun.id,
127+
}),
128+
createFakeArtifact({
129+
id: "many-artifact-4",
130+
key: "user-metrics",
131+
type: "table",
132+
description:
133+
"Daily active users: 2,500 rows with engagement scores",
134+
flow_run_id: manyFlowRun.id,
135+
}),
136+
createFakeArtifact({
137+
id: "many-artifact-5",
138+
key: "summary-notes",
139+
type: "markdown",
140+
description:
141+
"### Key Findings\n\n- Revenue up *12%*\n- Churn down *5%*",
142+
flow_run_id: manyFlowRun.id,
143+
}),
144+
createFakeArtifact({
145+
id: "many-artifact-6",
146+
key: "batch-progress",
147+
type: "progress",
148+
description:
149+
"Batch processing: 1,000/1,000 items complete (100%)",
150+
flow_run_id: manyFlowRun.id,
151+
}),
152+
]);
153+
}),
154+
],
155+
},
156+
},
157+
args: {
158+
flowRun: createFakeFlowRun({ id: "flow-run-many" }),
159+
},
160+
};
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { QueryClient } from "@tanstack/react-query";
2+
import {
3+
createMemoryHistory,
4+
createRootRoute,
5+
createRouter,
6+
RouterProvider,
7+
} from "@tanstack/react-router";
8+
import { fireEvent, render, screen } from "@testing-library/react";
9+
import { buildApiUrl, createWrapper, server } from "@tests/utils";
10+
import { HttpResponse, http } from "msw";
11+
import { beforeEach, describe, expect, it } from "vitest";
12+
import { createFakeArtifact, createFakeFlowRun } from "@/mocks";
13+
import { FlowRunArtifacts } from "./flow-run-artifacts";
14+
15+
const FlowRunArtifactsRouter = ({
16+
flowRun,
17+
}: {
18+
flowRun: ReturnType<typeof createFakeFlowRun>;
19+
}) => {
20+
const rootRoute = createRootRoute({
21+
component: () => <FlowRunArtifacts flowRun={flowRun} />,
22+
});
23+
24+
const router = createRouter({
25+
routeTree: rootRoute,
26+
history: createMemoryHistory({
27+
initialEntries: ["/"],
28+
}),
29+
context: { queryClient: new QueryClient() },
30+
});
31+
return <RouterProvider router={router} />;
32+
};
33+
34+
describe("FlowRunArtifacts", () => {
35+
const mockFlowRun = createFakeFlowRun();
36+
const mockArtifacts = Array.from({ length: 3 }, () =>
37+
createFakeArtifact({
38+
flow_run_id: mockFlowRun.id,
39+
}),
40+
);
41+
42+
beforeEach(() => {
43+
server.use(
44+
http.post(buildApiUrl("/artifacts/filter"), () => {
45+
return HttpResponse.json(mockArtifacts);
46+
}),
47+
);
48+
});
49+
50+
it("renders empty state when no artifacts are present", async () => {
51+
server.use(
52+
http.post(buildApiUrl("/artifacts/filter"), () => {
53+
return HttpResponse.json([]);
54+
}),
55+
);
56+
57+
render(<FlowRunArtifactsRouter flowRun={mockFlowRun} />, {
58+
wrapper: createWrapper(),
59+
});
60+
61+
expect(
62+
await screen.findByText(/This flow run did not produce any artifacts/),
63+
).toBeInTheDocument();
64+
expect(screen.getByRole("link", { name: /documentation/ })).toHaveAttribute(
65+
"href",
66+
"https://docs.prefect.io/v3/develop/artifacts",
67+
);
68+
});
69+
70+
it("switches between grid and list views", async () => {
71+
render(<FlowRunArtifactsRouter flowRun={mockFlowRun} />, {
72+
wrapper: createWrapper(),
73+
});
74+
75+
const gridButton = await screen.findByLabelText(/Grid view/i);
76+
const listButton = await screen.findByLabelText(/List view/i);
77+
const grid = await screen.findByTestId("flow-run-artifacts-grid");
78+
expect(grid).toHaveClass("grid-cols-1 lg:grid-cols-2 xl:grid-cols-3");
79+
80+
fireEvent.click(listButton);
81+
expect(grid).toHaveClass("grid-cols-1");
82+
83+
fireEvent.click(gridButton);
84+
expect(grid).toHaveClass("grid-cols-1 lg:grid-cols-2 xl:grid-cols-3");
85+
});
86+
87+
it("uses correct query parameters for fetching artifacts", async () => {
88+
let requestBody: unknown;
89+
server.use(
90+
http.post(buildApiUrl("/artifacts/filter"), async ({ request }) => {
91+
requestBody = await request.json();
92+
return HttpResponse.json(mockArtifacts);
93+
}),
94+
);
95+
96+
render(<FlowRunArtifactsRouter flowRun={mockFlowRun} />, {
97+
wrapper: createWrapper(),
98+
});
99+
100+
await screen.findByText(mockArtifacts[0].key as string);
101+
102+
expect(requestBody).toEqual({
103+
artifacts: {
104+
operator: "and_",
105+
flow_run_id: {
106+
any_: [mockFlowRun.id],
107+
},
108+
type: {
109+
not_any_: ["result"],
110+
},
111+
},
112+
sort: "ID_DESC",
113+
offset: 0,
114+
});
115+
});
116+
});
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { useSuspenseQuery } from "@tanstack/react-query";
2+
import { LayoutGrid, Rows3 } from "lucide-react";
3+
import { useState } from "react";
4+
import { buildListArtifactsQuery } from "@/api/artifacts";
5+
import type { FlowRun } from "@/api/flow-runs";
6+
import { ArtifactCard } from "@/components/artifacts/artifact-card";
7+
import { Card, CardContent } from "@/components/ui/card";
8+
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
9+
import { cn } from "@/utils";
10+
11+
type FlowRunArtifactsProps = {
12+
flowRun: FlowRun;
13+
};
14+
15+
export const FlowRunArtifacts = ({ flowRun }: FlowRunArtifactsProps) => {
16+
const [view, setView] = useState<"grid" | "list">("grid");
17+
const { data: artifacts } = useSuspenseQuery(
18+
buildListArtifactsQuery({
19+
artifacts: {
20+
operator: "and_",
21+
flow_run_id: {
22+
any_: [flowRun.id],
23+
},
24+
type: {
25+
not_any_: ["result"],
26+
},
27+
},
28+
sort: "ID_DESC",
29+
offset: 0,
30+
}),
31+
);
32+
if (artifacts.length === 0) {
33+
return (
34+
<Card>
35+
<CardContent className="text-center">
36+
<p>
37+
This flow run did not produce any artifacts; for more information on
38+
creating artifacts, see the{" "}
39+
<a
40+
href="https://docs.prefect.io/v3/develop/artifacts"
41+
target="_blank"
42+
rel="noopener noreferrer"
43+
className="text-blue-500"
44+
>
45+
documentation
46+
</a>
47+
.
48+
</p>
49+
</CardContent>
50+
</Card>
51+
);
52+
}
53+
54+
return (
55+
<div className="flex flex-col gap-4">
56+
<div className="flex justify-end">
57+
<ToggleGroup
58+
type="single"
59+
variant="outline"
60+
value={view}
61+
onValueChange={(value) => setView(value as "grid" | "list")}
62+
>
63+
<ToggleGroupItem value="grid" aria-label="Grid view">
64+
<LayoutGrid className="w-4 h-4" />
65+
</ToggleGroupItem>
66+
<ToggleGroupItem value="list" aria-label="List view">
67+
<Rows3 className="w-4 h-4" />
68+
</ToggleGroupItem>
69+
</ToggleGroup>
70+
</div>
71+
<div
72+
className={cn(
73+
"grid",
74+
view === "grid"
75+
? "grid-cols-1 lg:grid-cols-2 xl:grid-cols-3"
76+
: "grid-cols-1",
77+
"gap-4",
78+
)}
79+
data-testid="flow-run-artifacts-grid"
80+
>
81+
{artifacts.map((artifact) => (
82+
<ArtifactCard
83+
key={artifact.id}
84+
artifact={artifact}
85+
compact={view === "list"}
86+
/>
87+
))}
88+
</div>
89+
</div>
90+
);
91+
};

ui-v2/src/components/flow-runs/flow-run-details-page/index.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import { JsonInput } from "@/components/ui/json-input";
3737
import { Skeleton } from "@/components/ui/skeleton";
3838
import { StateBadge } from "@/components/ui/state-badge";
3939
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
40+
import { FlowRunArtifacts } from "./flow-run-artifacts";
4041
import { FlowRunDetails } from "./flow-run-details";
4142
import { FlowRunLogs } from "./flow-run-logs";
4243

@@ -107,7 +108,7 @@ export const FlowRunDetailsPage = ({
107108
}
108109
taskRunsContent={<PlaceholderContent label="Task Runs" />}
109110
subflowRunsContent={<PlaceholderContent label="Subflow Runs" />}
110-
artifactsContent={<PlaceholderContent label="Artifacts" />}
111+
artifactsContent={<FlowRunArtifacts flowRun={flowRun} />}
111112
detailsContent={<FlowRunDetails flowRun={flowRun} />}
112113
parametersContent={
113114
<div className="space-y-4">

0 commit comments

Comments
 (0)