Skip to content

Commit f6417e7

Browse files
authored
[UI v2] Add dashboard filters (#18950)
1 parent 23f96a2 commit f6417e7

File tree

4 files changed

+637
-8
lines changed

4 files changed

+637
-8
lines changed
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
2+
import { render, screen, waitFor, within } from "@testing-library/react";
3+
import userEvent from "@testing-library/user-event";
4+
import { HttpResponse, http } from "msw";
5+
import { setupServer } from "msw/node";
6+
import {
7+
afterAll,
8+
afterEach,
9+
beforeAll,
10+
describe,
11+
expect,
12+
it,
13+
vi,
14+
} from "vitest";
15+
import { FlowRunTagsSelect } from "./flow-run-tags-select";
16+
17+
// MSW server to mock API endpoints
18+
const server = setupServer(
19+
http.post("/api/flow_runs/paginate", () => {
20+
return HttpResponse.json({
21+
results: [
22+
{ id: "1", tags: ["alpha", "prod"] },
23+
{ id: "2", tags: ["beta", "prod"] },
24+
{ id: "3", tags: [] },
25+
],
26+
pages: 1,
27+
next: null,
28+
});
29+
}),
30+
);
31+
32+
beforeAll(() => {
33+
// Ensure the client builds requests against the MSW base URL
34+
vi.stubEnv("VITE_API_URL", "/api");
35+
// Polyfill scrollIntoView used by cmdk
36+
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
37+
value: vi.fn(),
38+
configurable: true,
39+
writable: true,
40+
});
41+
server.listen();
42+
});
43+
afterEach(() => server.resetHandlers());
44+
afterAll(() => server.close());
45+
46+
function renderWithQueryClient(ui: React.ReactElement) {
47+
const queryClient = new QueryClient({
48+
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
49+
});
50+
return render(
51+
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>,
52+
);
53+
}
54+
55+
describe("FlowRunTagsSelect", () => {
56+
it("shows placeholder when no tags selected", () => {
57+
renderWithQueryClient(
58+
<FlowRunTagsSelect
59+
value={[]}
60+
onChange={vi.fn()}
61+
placeholder="All tags"
62+
/>,
63+
);
64+
// The trigger shows the placeholder text
65+
expect(screen.getByText("All tags")).toBeInTheDocument();
66+
});
67+
68+
it("shows selected tags in the trigger", () => {
69+
renderWithQueryClient(
70+
<FlowRunTagsSelect value={["alpha", "beta"]} onChange={vi.fn()} />,
71+
);
72+
// Without opening dropdown, tags are visible in trigger
73+
expect(screen.getByText("alpha")).toBeInTheDocument();
74+
expect(screen.getByText("beta")).toBeInTheDocument();
75+
});
76+
77+
// Suggestion rendering is covered implicitly by other integration tests; core interactions below
78+
79+
it("adds a freeform tag on Enter", async () => {
80+
const onChange = vi.fn();
81+
const user = userEvent.setup();
82+
renderWithQueryClient(<FlowRunTagsSelect value={[]} onChange={onChange} />);
83+
84+
const trigger = screen.getByRole("button", { name: /flow run tags/i });
85+
await user.click(trigger);
86+
87+
const combo = screen.getByRole("combobox");
88+
await user.type(combo, "newtag");
89+
await user.keyboard("{Enter}");
90+
91+
expect(onChange).toHaveBeenCalledWith(["newtag"]);
92+
});
93+
94+
it("adds a tag when typing a trailing comma", async () => {
95+
const onChange = vi.fn();
96+
const user = userEvent.setup();
97+
renderWithQueryClient(<FlowRunTagsSelect value={[]} onChange={onChange} />);
98+
99+
const trigger = screen.getByRole("button", { name: /flow run tags/i });
100+
await user.click(trigger);
101+
102+
const input2 = screen.getByRole("combobox");
103+
await user.type(input2, "temp,");
104+
105+
// Comma commits the tag and clears input; we expect onChange invoked
106+
await waitFor(() => {
107+
expect(onChange).toHaveBeenCalledWith(["temp"]);
108+
});
109+
});
110+
111+
it("removes last tag on Backspace when input empty", async () => {
112+
const onChange = vi.fn();
113+
const user = userEvent.setup();
114+
// Start with one selected tag
115+
renderWithQueryClient(
116+
<FlowRunTagsSelect value={["alpha"]} onChange={onChange} />,
117+
);
118+
119+
const trigger = screen.getByRole("button", { name: /flow run tags/i });
120+
await user.click(trigger);
121+
122+
await user.keyboard("{Backspace}");
123+
124+
expect(onChange).toHaveBeenCalledWith([]);
125+
});
126+
127+
it("removes a tag via dropdown remove button", async () => {
128+
const onChange = vi.fn();
129+
const user = userEvent.setup();
130+
renderWithQueryClient(
131+
<FlowRunTagsSelect value={["alpha", "beta"]} onChange={onChange} />,
132+
);
133+
134+
// Open and remove 'alpha' via dropdown
135+
const trigger = screen.getByRole("button", { name: /flow run tags/i });
136+
await user.click(trigger);
137+
const removeAlpha = await screen.findByRole("button", {
138+
name: /remove alpha tag/i,
139+
});
140+
await user.click(removeAlpha);
141+
142+
expect(onChange).toHaveBeenCalledWith(["beta"]);
143+
});
144+
145+
it("clears all tags via clear action", async () => {
146+
const onChange = vi.fn();
147+
const user = userEvent.setup();
148+
renderWithQueryClient(
149+
<FlowRunTagsSelect value={["alpha", "beta"]} onChange={onChange} />,
150+
);
151+
152+
const trigger = screen.getByRole("button", { name: /flow run tags/i });
153+
await user.click(trigger);
154+
155+
const listbox = await screen.findByRole("listbox");
156+
// Clear all item should be visible regardless of suggestions
157+
const clearItem = await within(listbox).findByText(/clear all tags/i);
158+
await user.click(clearItem);
159+
160+
expect(onChange).toHaveBeenCalledWith([]);
161+
});
162+
163+
it("does not show 'No tags found' when API returns no tags", async () => {
164+
// Return empty results
165+
server.use(
166+
http.post("/api/flow_runs/paginate", () =>
167+
HttpResponse.json({ results: [], pages: 0, next: null }),
168+
),
169+
);
170+
171+
const user = userEvent.setup();
172+
renderWithQueryClient(<FlowRunTagsSelect value={[]} onChange={vi.fn()} />);
173+
174+
const trigger = screen.getByRole("button", { name: /flow run tags/i });
175+
await user.click(trigger);
176+
177+
// There is a listbox but no empty message
178+
const listbox = await screen.findByRole("listbox");
179+
expect(
180+
within(listbox).queryByText(/no tags found/i),
181+
).not.toBeInTheDocument();
182+
});
183+
});
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import { useQuery } from "@tanstack/react-query";
2+
import { useCallback, useEffect, useMemo, useState } from "react";
3+
import { buildPaginateFlowRunsQuery } from "@/api/flow-runs";
4+
import {
5+
Combobox,
6+
ComboboxCommandEmtpy,
7+
ComboboxCommandGroup,
8+
ComboboxCommandInput,
9+
ComboboxCommandItem,
10+
ComboboxCommandList,
11+
ComboboxContent,
12+
ComboboxTrigger,
13+
} from "@/components/ui/combobox";
14+
import { Icon } from "@/components/ui/icons";
15+
import { TagBadgeGroup } from "@/components/ui/tag-badge-group";
16+
17+
type FlowRunTagsSelectProps = {
18+
value?: string[];
19+
onChange?: (tags: string[]) => void;
20+
placeholder?: string;
21+
id?: string;
22+
};
23+
24+
export function FlowRunTagsSelect({
25+
value = [],
26+
onChange,
27+
placeholder = "All tags",
28+
id,
29+
}: FlowRunTagsSelectProps) {
30+
const [search, setSearch] = useState("");
31+
32+
// Fetch a recent page of flow runs to derive tag suggestions
33+
const { data } = useQuery(
34+
buildPaginateFlowRunsQuery({
35+
page: 1,
36+
limit: 100,
37+
sort: "START_TIME_DESC",
38+
}),
39+
);
40+
41+
const suggestions = useMemo(() => {
42+
const all = new Set<string>();
43+
(data?.results ?? []).forEach((fr) => {
44+
(fr.tags ?? []).forEach((t) => all.add(t));
45+
});
46+
return Array.from(all).sort((a, b) => a.localeCompare(b));
47+
}, [data?.results]);
48+
49+
// Computed suggestions are built inline below; keep ranking helpers here if needed later
50+
51+
const toggleTag = (tag: string) => {
52+
const exists = value.includes(tag);
53+
const next = exists ? value.filter((t) => t !== tag) : [...value, tag];
54+
onChange?.(next);
55+
};
56+
57+
const addFreeformTag = useCallback(
58+
(tag: string) => {
59+
if (!tag) return;
60+
if (value.includes(tag)) return;
61+
onChange?.([...value, tag]);
62+
},
63+
[onChange, value],
64+
);
65+
66+
// Add on trailing comma for quick entry (matches common tags UX)
67+
useEffect(() => {
68+
const trimmed = search.trim();
69+
if (trimmed.endsWith(",")) {
70+
const tag = trimmed.replace(/,+$/, "").trim();
71+
if (tag) {
72+
addFreeformTag(tag);
73+
}
74+
setSearch("");
75+
}
76+
}, [search, addFreeformTag]);
77+
78+
const triggerLabel = (
79+
<span className="text-muted-foreground">
80+
{value.length ? "Edit tags" : placeholder}
81+
</span>
82+
);
83+
84+
return (
85+
<div className="w-full">
86+
<Combobox>
87+
<ComboboxTrigger
88+
aria-label="Flow run tags"
89+
id={id}
90+
selected={value.length === 0}
91+
>
92+
{value.length > 0 ? (
93+
<div className="flex items-center gap-1 overflow-hidden">
94+
<TagBadgeGroup
95+
tags={value}
96+
variant="secondary"
97+
maxTagsDisplayed={3}
98+
/>
99+
</div>
100+
) : (
101+
triggerLabel
102+
)}
103+
</ComboboxTrigger>
104+
<ComboboxContent>
105+
<ComboboxCommandInput
106+
value={search}
107+
onValueChange={setSearch}
108+
placeholder="Search or enter new tag"
109+
onKeyDown={(e) => {
110+
const query = search.trim();
111+
if (e.key === "Enter" && query) {
112+
e.preventDefault();
113+
addFreeformTag(query);
114+
setSearch("");
115+
} else if (e.key === "Backspace" && !search && value.length > 0) {
116+
onChange?.(value.slice(0, -1));
117+
}
118+
}}
119+
/>
120+
<ComboboxCommandList>
121+
{suggestions.length > 0 ? (
122+
<ComboboxCommandEmtpy>No tags found</ComboboxCommandEmtpy>
123+
) : null}
124+
<ComboboxCommandGroup>
125+
{/* Selected tags with inline remove */}
126+
{value.length > 0 && (
127+
<div>
128+
{value.map((tag) => (
129+
<ComboboxCommandItem
130+
key={`selected-${tag}`}
131+
value={`selected-${tag}`}
132+
onSelect={() => toggleTag(tag)}
133+
closeOnSelect={false}
134+
>
135+
<button
136+
type="button"
137+
aria-label={`Remove ${tag} tag`}
138+
className="text-muted-foreground hover:text-foreground"
139+
onClick={(ev) => {
140+
ev.preventDefault();
141+
ev.stopPropagation();
142+
onChange?.(value.filter((t) => t !== tag));
143+
}}
144+
>
145+
<Icon id="X" className="size-3" />
146+
</button>
147+
<span>{tag}</span>
148+
</ComboboxCommandItem>
149+
))}
150+
<ComboboxCommandItem
151+
value="__clear__"
152+
onSelect={() => onChange?.([])}
153+
closeOnSelect={false}
154+
>
155+
Clear all tags
156+
</ComboboxCommandItem>
157+
</div>
158+
)}
159+
160+
{/* Add freeform */}
161+
{search.trim() && !value.includes(search.trim()) && (
162+
<ComboboxCommandItem
163+
value={`__add__:${search.trim()}`}
164+
onSelect={() => addFreeformTag(search.trim())}
165+
closeOnSelect={false}
166+
>
167+
Add &quot;{search.trim()}&quot;
168+
</ComboboxCommandItem>
169+
)}
170+
171+
{/* Suggestions (exclude already selected) */}
172+
{suggestions
173+
.filter((t) => !value.includes(t))
174+
.filter((t) => {
175+
const q = search.trim().toLowerCase();
176+
if (!q) return true;
177+
return t.toLowerCase().includes(q);
178+
})
179+
.map((tag) => (
180+
<ComboboxCommandItem
181+
key={tag}
182+
value={tag}
183+
selected={value.includes(tag)}
184+
onSelect={() => toggleTag(tag)}
185+
closeOnSelect={false}
186+
>
187+
{tag}
188+
</ComboboxCommandItem>
189+
))}
190+
</ComboboxCommandGroup>
191+
</ComboboxCommandList>
192+
</ComboboxContent>
193+
</Combobox>
194+
</div>
195+
);
196+
}
197+
198+
FlowRunTagsSelect.displayName = "FlowRunTagsSelect";

0 commit comments

Comments
 (0)