Skip to content

Commit c377987

Browse files
Add flow filter combobox to /runs page (#19724)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: [email protected] <[email protected]>
1 parent 173bc43 commit c377987

File tree

9 files changed

+377
-2
lines changed

9 files changed

+377
-2
lines changed
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
3+
import { useState } from "react";
4+
import { FlowFilter } from "./flow-filter";
5+
6+
const queryClient = new QueryClient({
7+
defaultOptions: {
8+
queries: {
9+
retry: false,
10+
},
11+
},
12+
});
13+
14+
const meta: Meta<typeof FlowFilter> = {
15+
title: "Components/FlowRuns/FlowFilter",
16+
component: FlowFilter,
17+
decorators: [
18+
(Story) => (
19+
<QueryClientProvider client={queryClient}>
20+
<div className="w-64">
21+
<Story />
22+
</div>
23+
</QueryClientProvider>
24+
),
25+
],
26+
parameters: {
27+
docs: {
28+
description: {
29+
component:
30+
"A combobox filter for selecting flows to filter flow runs by.",
31+
},
32+
},
33+
},
34+
};
35+
36+
export default meta;
37+
type Story = StoryObj<typeof FlowFilter>;
38+
39+
const FlowFilterWithState = ({
40+
initialSelectedFlows = new Set<string>(),
41+
}: {
42+
initialSelectedFlows?: Set<string>;
43+
}) => {
44+
const [selectedFlows, setSelectedFlows] =
45+
useState<Set<string>>(initialSelectedFlows);
46+
return (
47+
<FlowFilter
48+
selectedFlows={selectedFlows}
49+
onSelectFlows={setSelectedFlows}
50+
/>
51+
);
52+
};
53+
54+
export const Default: Story = {
55+
render: () => <FlowFilterWithState />,
56+
};
57+
58+
export const WithSelectedFlows: Story = {
59+
render: () => (
60+
<FlowFilterWithState initialSelectedFlows={new Set(["flow-1", "flow-2"])} />
61+
),
62+
};
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
2+
import { render, screen, waitFor } from "@testing-library/react";
3+
import userEvent from "@testing-library/user-event";
4+
import { useState } from "react";
5+
import { beforeAll, describe, expect, it, vi } from "vitest";
6+
import { FlowFilter } from "./flow-filter";
7+
8+
beforeAll(() => {
9+
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
10+
value: vi.fn(),
11+
configurable: true,
12+
writable: true,
13+
});
14+
});
15+
16+
function renderWithQueryClient(ui: React.ReactElement) {
17+
const queryClient = new QueryClient({
18+
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
19+
});
20+
return render(
21+
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>,
22+
);
23+
}
24+
25+
describe("FlowFilter", () => {
26+
const TestFlowFilter = ({
27+
initialSelectedFlows = new Set<string>(),
28+
}: {
29+
initialSelectedFlows?: Set<string>;
30+
}) => {
31+
const [selectedFlows, setSelectedFlows] =
32+
useState<Set<string>>(initialSelectedFlows);
33+
return (
34+
<FlowFilter
35+
selectedFlows={selectedFlows}
36+
onSelectFlows={setSelectedFlows}
37+
/>
38+
);
39+
};
40+
41+
it("renders with 'All flows' when no flows are selected", () => {
42+
renderWithQueryClient(<TestFlowFilter />);
43+
44+
expect(
45+
screen.getByRole("button", { name: /filter by flow/i }),
46+
).toBeVisible();
47+
expect(screen.getByText("All flows")).toBeVisible();
48+
});
49+
50+
it("opens dropdown and shows 'All flows' option", async () => {
51+
const user = userEvent.setup();
52+
renderWithQueryClient(<TestFlowFilter />);
53+
54+
await user.click(screen.getByRole("button", { name: /filter by flow/i }));
55+
56+
await waitFor(() => {
57+
expect(screen.getByPlaceholderText("Search flows...")).toBeVisible();
58+
});
59+
60+
expect(screen.getByRole("option", { name: /all flows/i })).toBeVisible();
61+
});
62+
63+
it("shows search input in dropdown", async () => {
64+
const user = userEvent.setup();
65+
renderWithQueryClient(<TestFlowFilter />);
66+
67+
await user.click(screen.getByRole("button", { name: /filter by flow/i }));
68+
69+
await waitFor(() => {
70+
expect(screen.getByPlaceholderText("Search flows...")).toBeVisible();
71+
});
72+
});
73+
74+
it("has 'All flows' checkbox checked by default", async () => {
75+
const user = userEvent.setup();
76+
renderWithQueryClient(<TestFlowFilter />);
77+
78+
await user.click(screen.getByRole("button", { name: /filter by flow/i }));
79+
80+
await waitFor(() => {
81+
expect(screen.getByRole("option", { name: /all flows/i })).toBeVisible();
82+
});
83+
84+
const allFlowsOption = screen.getByRole("option", { name: /all flows/i });
85+
const allFlowsCheckbox = allFlowsOption.querySelector('[role="checkbox"]');
86+
expect(allFlowsCheckbox).toHaveAttribute("data-state", "checked");
87+
});
88+
});
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import { useQuery } from "@tanstack/react-query";
2+
import { useDeferredValue, useMemo, useState } from "react";
3+
import { buildListFlowsQuery, type Flow } from "@/api/flows";
4+
import { Checkbox } from "@/components/ui/checkbox";
5+
import {
6+
Combobox,
7+
ComboboxCommandEmtpy,
8+
ComboboxCommandGroup,
9+
ComboboxCommandInput,
10+
ComboboxCommandItem,
11+
ComboboxCommandList,
12+
ComboboxContent,
13+
ComboboxTrigger,
14+
} from "@/components/ui/combobox";
15+
import { Typography } from "@/components/ui/typography";
16+
17+
const MAX_FLOWS_DISPLAYED = 2;
18+
19+
type FlowFilterProps = {
20+
selectedFlows: Set<string>;
21+
onSelectFlows: (flows: Set<string>) => void;
22+
};
23+
24+
export const FlowFilter = ({
25+
selectedFlows,
26+
onSelectFlows,
27+
}: FlowFilterProps) => {
28+
const [search, setSearch] = useState("");
29+
const deferredSearch = useDeferredValue(search);
30+
31+
const { data: flows = [] } = useQuery(
32+
buildListFlowsQuery({
33+
flows: deferredSearch
34+
? {
35+
operator: "and_",
36+
name: { like_: deferredSearch },
37+
}
38+
: undefined,
39+
limit: 100,
40+
offset: 0,
41+
sort: "NAME_ASC",
42+
}),
43+
);
44+
45+
const { data: selectedFlowsData = [] } = useQuery(
46+
buildListFlowsQuery(
47+
{
48+
flows:
49+
selectedFlows.size > 0
50+
? {
51+
operator: "and_",
52+
id: { any_: Array.from(selectedFlows) },
53+
}
54+
: undefined,
55+
limit: selectedFlows.size || 1,
56+
offset: 0,
57+
sort: "NAME_ASC",
58+
},
59+
{ enabled: selectedFlows.size > 0 },
60+
),
61+
);
62+
63+
const handleSelectFlow = (flowId: string) => {
64+
const updatedFlows = new Set(selectedFlows);
65+
if (selectedFlows.has(flowId)) {
66+
updatedFlows.delete(flowId);
67+
} else {
68+
updatedFlows.add(flowId);
69+
}
70+
onSelectFlows(updatedFlows);
71+
};
72+
73+
const handleClearAll = () => {
74+
onSelectFlows(new Set());
75+
};
76+
77+
const renderSelectedFlows = () => {
78+
if (selectedFlows.size === 0) {
79+
return "All flows";
80+
}
81+
82+
const selectedFlowNames = selectedFlowsData
83+
.filter((flow) => selectedFlows.has(flow.id))
84+
.map((flow) => flow.name);
85+
86+
const visible = selectedFlowNames.slice(0, MAX_FLOWS_DISPLAYED);
87+
const extraCount = selectedFlowNames.length - MAX_FLOWS_DISPLAYED;
88+
89+
return (
90+
<div className="flex flex-1 min-w-0 items-center gap-2">
91+
<div className="flex flex-1 min-w-0 items-center gap-2 overflow-hidden">
92+
<span className="truncate">{visible.join(", ")}</span>
93+
</div>
94+
{extraCount > 0 && (
95+
<Typography variant="bodySmall" className="shrink-0">
96+
+ {extraCount}
97+
</Typography>
98+
)}
99+
</div>
100+
);
101+
};
102+
103+
const filteredFlows = useMemo(() => {
104+
return flows.filter(
105+
(flow: Flow) =>
106+
!deferredSearch ||
107+
flow.name.toLowerCase().includes(deferredSearch.toLowerCase()),
108+
);
109+
}, [flows, deferredSearch]);
110+
111+
return (
112+
<Combobox>
113+
<ComboboxTrigger
114+
aria-label="Filter by flow"
115+
selected={selectedFlows.size === 0}
116+
>
117+
{renderSelectedFlows()}
118+
</ComboboxTrigger>
119+
<ComboboxContent>
120+
<ComboboxCommandInput
121+
value={search}
122+
onValueChange={setSearch}
123+
placeholder="Search flows..."
124+
/>
125+
<ComboboxCommandList>
126+
<ComboboxCommandEmtpy>No flows found</ComboboxCommandEmtpy>
127+
<ComboboxCommandGroup>
128+
<ComboboxCommandItem
129+
aria-label="All flows"
130+
onSelect={handleClearAll}
131+
closeOnSelect={false}
132+
value="__all__"
133+
>
134+
<Checkbox checked={selectedFlows.size === 0} />
135+
All flows
136+
</ComboboxCommandItem>
137+
{filteredFlows.map((flow: Flow) => (
138+
<ComboboxCommandItem
139+
key={flow.id}
140+
aria-label={flow.name}
141+
onSelect={() => handleSelectFlow(flow.id)}
142+
closeOnSelect={false}
143+
value={flow.id}
144+
>
145+
<Checkbox checked={selectedFlows.has(flow.id)} />
146+
{flow.name}
147+
</ComboboxCommandItem>
148+
))}
149+
</ComboboxCommandGroup>
150+
</ComboboxCommandList>
151+
</ComboboxContent>
152+
</Combobox>
153+
);
154+
};

ui-v2/src/components/flow-runs/flow-runs-list/flow-runs-pagination.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ export const FlowRunsPagination = ({
110110
/>
111111
</PaginationItem>
112112
<PaginationItem className="text-sm">
113-
Page {pagination.page} of {pages}
113+
Page {pages === 0 ? 0 : pagination.page} of {pages}
114114
</PaginationItem>
115115
<PaginationItem>
116116
<PaginationNextButton

ui-v2/src/components/flow-runs/flow-runs-list/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export {
77
dateRangeValueToUrlState,
88
urlStateToDateRangeValue,
99
} from "./flow-runs-filters/date-range-url-state";
10+
export { FlowFilter } from "./flow-runs-filters/flow-filter";
1011
export {
1112
SORT_FILTERS,
1213
type SortFilters,

ui-v2/src/components/runs/runs-page.stories.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ const RunsPageWithState = ({
8080
initialFlowRunSearch = "",
8181
initialSelectedStates = new Set<FlowRunState>(),
8282
initialDateRange = {},
83+
initialSelectedFlows = new Set<string>(),
8384
}: {
8485
initialFlowRuns?: typeof MOCK_FLOW_RUNS;
8586
initialFlowRunsCount?: number;
@@ -88,6 +89,7 @@ const RunsPageWithState = ({
8889
initialFlowRunSearch?: string;
8990
initialSelectedStates?: Set<FlowRunState>;
9091
initialDateRange?: DateRangeUrlState;
92+
initialSelectedFlows?: Set<string>;
9193
}) => {
9294
const [tab, setTab] = useState<"flow-runs" | "task-runs">("flow-runs");
9395
const [pagination, setPagination] = useState<PaginationState>({
@@ -102,6 +104,8 @@ const RunsPageWithState = ({
102104
);
103105
const [dateRange, setDateRange] =
104106
useState<DateRangeUrlState>(initialDateRange);
107+
const [selectedFlows, setSelectedFlows] =
108+
useState<Set<string>>(initialSelectedFlows);
105109

106110
return (
107111
<RunsPage
@@ -121,6 +125,8 @@ const RunsPageWithState = ({
121125
onFlowRunSearchChange={setFlowRunSearch}
122126
selectedStates={selectedStates}
123127
onStateFilterChange={setSelectedStates}
128+
selectedFlows={selectedFlows}
129+
onFlowFilterChange={setSelectedFlows}
124130
dateRange={dateRange}
125131
onDateRangeChange={setDateRange}
126132
/>

ui-v2/src/components/runs/runs-page.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { FlowRunCardData } from "@/components/flow-runs/flow-run-card";
55
import {
66
DateRangeFilter,
77
type DateRangeUrlState,
8+
FlowFilter,
89
type FlowRunState,
910
FlowRunsList,
1011
FlowRunsPagination,
@@ -47,6 +48,8 @@ type RunsPageProps = {
4748
onFlowRunSearchChange: (search: string) => void;
4849
selectedStates: Set<FlowRunState>;
4950
onStateFilterChange: (states: Set<FlowRunState>) => void;
51+
selectedFlows: Set<string>;
52+
onFlowFilterChange: (flows: Set<string>) => void;
5053
dateRange: DateRangeUrlState;
5154
onDateRangeChange: (dateRange: DateRangeUrlState) => void;
5255
};
@@ -69,6 +72,8 @@ export const RunsPage = ({
6972
onFlowRunSearchChange,
7073
selectedStates,
7174
onStateFilterChange,
75+
selectedFlows,
76+
onFlowFilterChange,
7277
dateRange,
7378
onDateRangeChange,
7479
}: RunsPageProps) => {
@@ -125,6 +130,12 @@ export const RunsPage = ({
125130
onSelectFilter={onStateFilterChange}
126131
/>
127132
</div>
133+
<div className="w-64">
134+
<FlowFilter
135+
selectedFlows={selectedFlows}
136+
onSelectFlows={onFlowFilterChange}
137+
/>
138+
</div>
128139
<DateRangeFilter
129140
value={dateRange}
130141
onValueChange={onDateRangeChange}

0 commit comments

Comments
 (0)