Skip to content

Commit 702e058

Browse files
committed
Annotation Filter input
1 parent 9a6b47d commit 702e058

2 files changed

Lines changed: 347 additions & 0 deletions

File tree

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
import { fireEvent, render, screen } from "@testing-library/react";
2+
import userEvent from "@testing-library/user-event";
3+
import { describe, expect, it, vi } from "vitest";
4+
5+
import { AnnotationFilterInput } from "./AnnotationFilterInput";
6+
7+
describe("AnnotationFilterInput", () => {
8+
describe("empty state", () => {
9+
it("should show add filter button when no filters", () => {
10+
render(<AnnotationFilterInput filters={[]} onChange={vi.fn()} />);
11+
12+
expect(screen.getByText("Annotations:")).toBeInTheDocument();
13+
expect(screen.getByRole("button", { name: /add filter/i })).toBeInTheDocument();
14+
});
15+
16+
it("should not show any badges when no filters", () => {
17+
render(<AnnotationFilterInput filters={[]} onChange={vi.fn()} />);
18+
19+
expect(screen.queryByRole("button", { name: /remove/i })).not.toBeInTheDocument();
20+
});
21+
});
22+
23+
describe("expanding input form", () => {
24+
it("should show input fields when add filter is clicked", async () => {
25+
const user = userEvent.setup();
26+
render(<AnnotationFilterInput filters={[]} onChange={vi.fn()} />);
27+
28+
await user.click(screen.getByRole("button", { name: /add filter/i }));
29+
30+
expect(screen.getByPlaceholderText("Key")).toBeInTheDocument();
31+
expect(screen.getByPlaceholderText("Value (optional)")).toBeInTheDocument();
32+
expect(screen.getByRole("button", { name: "Add" })).toBeInTheDocument();
33+
});
34+
35+
it("should focus key input when expanded", async () => {
36+
const user = userEvent.setup();
37+
render(<AnnotationFilterInput filters={[]} onChange={vi.fn()} />);
38+
39+
await user.click(screen.getByRole("button", { name: /add filter/i }));
40+
41+
expect(screen.getByPlaceholderText("Key")).toHaveFocus();
42+
});
43+
44+
it("should collapse when cancel button is clicked", async () => {
45+
const user = userEvent.setup();
46+
render(<AnnotationFilterInput filters={[]} onChange={vi.fn()} />);
47+
48+
await user.click(screen.getByRole("button", { name: /add filter/i }));
49+
expect(screen.getByPlaceholderText("Key")).toBeInTheDocument();
50+
51+
const closeButtons = screen.getAllByRole("button");
52+
const cancelButton = closeButtons.find(
53+
(btn) => btn.querySelector(".lucide-x") !== null,
54+
);
55+
if (cancelButton) {
56+
await user.click(cancelButton);
57+
}
58+
59+
expect(screen.queryByPlaceholderText("Key")).not.toBeInTheDocument();
60+
expect(screen.getByRole("button", { name: /add filter/i })).toBeInTheDocument();
61+
});
62+
});
63+
64+
describe("adding filters", () => {
65+
it("should add filter with key only", async () => {
66+
const user = userEvent.setup();
67+
const onChange = vi.fn();
68+
render(<AnnotationFilterInput filters={[]} onChange={onChange} />);
69+
70+
await user.click(screen.getByRole("button", { name: /add filter/i }));
71+
await user.type(screen.getByPlaceholderText("Key"), "env");
72+
await user.click(screen.getByRole("button", { name: "Add" }));
73+
74+
expect(onChange).toHaveBeenCalledWith([{ key: "env" }]);
75+
});
76+
77+
it("should add filter with key and value", async () => {
78+
const user = userEvent.setup();
79+
const onChange = vi.fn();
80+
render(<AnnotationFilterInput filters={[]} onChange={onChange} />);
81+
82+
await user.click(screen.getByRole("button", { name: /add filter/i }));
83+
await user.type(screen.getByPlaceholderText("Key"), "team");
84+
await user.type(screen.getByPlaceholderText("Value (optional)"), "ml");
85+
await user.click(screen.getByRole("button", { name: "Add" }));
86+
87+
expect(onChange).toHaveBeenCalledWith([{ key: "team", value: "ml" }]);
88+
});
89+
90+
it("should trim whitespace from key and value", async () => {
91+
const user = userEvent.setup();
92+
const onChange = vi.fn();
93+
render(<AnnotationFilterInput filters={[]} onChange={onChange} />);
94+
95+
await user.click(screen.getByRole("button", { name: /add filter/i }));
96+
await user.type(screen.getByPlaceholderText("Key"), " team ");
97+
await user.type(screen.getByPlaceholderText("Value (optional)"), " ml ");
98+
await user.click(screen.getByRole("button", { name: "Add" }));
99+
100+
expect(onChange).toHaveBeenCalledWith([{ key: "team", value: "ml" }]);
101+
});
102+
103+
it("should disable add button when key is empty", async () => {
104+
const user = userEvent.setup();
105+
render(<AnnotationFilterInput filters={[]} onChange={vi.fn()} />);
106+
107+
await user.click(screen.getByRole("button", { name: /add filter/i }));
108+
109+
expect(screen.getByRole("button", { name: "Add" })).toBeDisabled();
110+
});
111+
112+
it("should clear inputs after adding", async () => {
113+
const user = userEvent.setup();
114+
render(<AnnotationFilterInput filters={[]} onChange={vi.fn()} />);
115+
116+
await user.click(screen.getByRole("button", { name: /add filter/i }));
117+
await user.type(screen.getByPlaceholderText("Key"), "team");
118+
await user.type(screen.getByPlaceholderText("Value (optional)"), "ml");
119+
await user.click(screen.getByRole("button", { name: "Add" }));
120+
121+
expect(screen.queryByPlaceholderText("Key")).not.toBeInTheDocument();
122+
});
123+
124+
it("should add filter on Enter key press", async () => {
125+
const user = userEvent.setup();
126+
const onChange = vi.fn();
127+
render(<AnnotationFilterInput filters={[]} onChange={onChange} />);
128+
129+
await user.click(screen.getByRole("button", { name: /add filter/i }));
130+
await user.type(screen.getByPlaceholderText("Key"), "team");
131+
await user.keyboard("{Enter}");
132+
133+
expect(onChange).toHaveBeenCalledWith([{ key: "team" }]);
134+
});
135+
136+
it("should collapse on Escape key press", async () => {
137+
const user = userEvent.setup();
138+
render(<AnnotationFilterInput filters={[]} onChange={vi.fn()} />);
139+
140+
await user.click(screen.getByRole("button", { name: /add filter/i }));
141+
await user.keyboard("{Escape}");
142+
143+
expect(screen.queryByPlaceholderText("Key")).not.toBeInTheDocument();
144+
});
145+
});
146+
147+
describe("displaying filters", () => {
148+
it("should display filter with key only", () => {
149+
render(
150+
<AnnotationFilterInput
151+
filters={[{ key: "env" }]}
152+
onChange={vi.fn()}
153+
/>,
154+
);
155+
156+
expect(screen.getByText("env")).toBeInTheDocument();
157+
});
158+
159+
it("should display filter with key and value", () => {
160+
render(
161+
<AnnotationFilterInput
162+
filters={[{ key: "team", value: "ml" }]}
163+
onChange={vi.fn()}
164+
/>,
165+
);
166+
167+
expect(screen.getByText("team: ml")).toBeInTheDocument();
168+
});
169+
170+
it("should display multiple filters", () => {
171+
render(
172+
<AnnotationFilterInput
173+
filters={[
174+
{ key: "team", value: "ml" },
175+
{ key: "env", value: "prod" },
176+
{ key: "priority" },
177+
]}
178+
onChange={vi.fn()}
179+
/>,
180+
);
181+
182+
expect(screen.getByText("team: ml")).toBeInTheDocument();
183+
expect(screen.getByText("env: prod")).toBeInTheDocument();
184+
expect(screen.getByText("priority")).toBeInTheDocument();
185+
});
186+
});
187+
188+
describe("removing filters", () => {
189+
it("should remove filter when remove button is clicked", async () => {
190+
const user = userEvent.setup();
191+
const onChange = vi.fn();
192+
render(
193+
<AnnotationFilterInput
194+
filters={[
195+
{ key: "team", value: "ml" },
196+
{ key: "env", value: "prod" },
197+
]}
198+
onChange={onChange}
199+
/>,
200+
);
201+
202+
const removeButton = screen.getByRole("button", {
203+
name: /remove team filter/i,
204+
});
205+
await user.click(removeButton);
206+
207+
expect(onChange).toHaveBeenCalledWith([{ key: "env", value: "prod" }]);
208+
});
209+
210+
it("should have accessible remove button", () => {
211+
render(
212+
<AnnotationFilterInput
213+
filters={[{ key: "team", value: "ml" }]}
214+
onChange={vi.fn()}
215+
/>,
216+
);
217+
218+
expect(
219+
screen.getByRole("button", { name: /remove team filter/i }),
220+
).toBeInTheDocument();
221+
});
222+
});
223+
});
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { Plus, X } from "lucide-react";
2+
import { useState } from "react";
3+
4+
import { Badge } from "@/components/ui/badge";
5+
import { Button } from "@/components/ui/button";
6+
import { Input } from "@/components/ui/input";
7+
import { InlineStack } from "@/components/ui/layout";
8+
import { Text } from "@/components/ui/typography";
9+
import type { AnnotationFilter } from "@/types/pipelineRunFilters";
10+
11+
interface AnnotationFilterInputProps {
12+
filters: AnnotationFilter[];
13+
onChange: (filters: AnnotationFilter[]) => void;
14+
}
15+
16+
export function AnnotationFilterInput({
17+
filters,
18+
onChange,
19+
}: AnnotationFilterInputProps) {
20+
const [isExpanded, setIsExpanded] = useState(false);
21+
const [keyInput, setKeyInput] = useState("");
22+
const [valueInput, setValueInput] = useState("");
23+
24+
const handleAdd = () => {
25+
const trimmedKey = keyInput.trim();
26+
if (!trimmedKey) return;
27+
28+
const trimmedValue = valueInput.trim();
29+
const newFilter: AnnotationFilter = trimmedValue
30+
? { key: trimmedKey, value: trimmedValue }
31+
: { key: trimmedKey };
32+
33+
onChange([...filters, newFilter]);
34+
setKeyInput("");
35+
setValueInput("");
36+
setIsExpanded(false);
37+
};
38+
39+
const handleRemove = (index: number) => {
40+
onChange(filters.filter((_, i) => i !== index));
41+
};
42+
43+
const handleKeyDown = (e: React.KeyboardEvent) => {
44+
if (e.key === "Enter" && keyInput.trim()) {
45+
e.preventDefault();
46+
handleAdd();
47+
}
48+
if (e.key === "Escape") {
49+
setIsExpanded(false);
50+
setKeyInput("");
51+
setValueInput("");
52+
}
53+
};
54+
55+
return (
56+
<InlineStack gap="2" align="center">
57+
<Text size="sm" tone="subdued">
58+
Annotations:
59+
</Text>
60+
61+
{!isExpanded ? (
62+
<Button
63+
variant="outline"
64+
size="sm"
65+
onClick={() => setIsExpanded(true)}
66+
>
67+
<Plus className="mr-1 h-3 w-3" />
68+
Add filter
69+
</Button>
70+
) : (
71+
<InlineStack gap="1" align="center">
72+
<Input
73+
placeholder="Key"
74+
value={keyInput}
75+
onChange={(e) => setKeyInput(e.target.value)}
76+
onKeyDown={handleKeyDown}
77+
className="w-28 h-8 text-sm"
78+
autoFocus
79+
/>
80+
<Input
81+
placeholder="Value (optional)"
82+
value={valueInput}
83+
onChange={(e) => setValueInput(e.target.value)}
84+
onKeyDown={handleKeyDown}
85+
className="w-36 h-8 text-sm"
86+
/>
87+
<Button
88+
variant="outline"
89+
size="sm"
90+
onClick={handleAdd}
91+
disabled={!keyInput.trim()}
92+
>
93+
Add
94+
</Button>
95+
<Button
96+
variant="ghost"
97+
size="sm"
98+
onClick={() => {
99+
setIsExpanded(false);
100+
setKeyInput("");
101+
setValueInput("");
102+
}}
103+
>
104+
<X className="h-3 w-3" />
105+
</Button>
106+
</InlineStack>
107+
)}
108+
109+
{filters.map((filter, index) => (
110+
<Badge key={index} variant="secondary">
111+
{filter.key}
112+
{filter.value && `: ${filter.value}`}
113+
<button
114+
onClick={() => handleRemove(index)}
115+
className="ml-1 hover:text-destructive"
116+
aria-label={`Remove ${filter.key} filter`}
117+
>
118+
<X className="h-3 w-3" />
119+
</button>
120+
</Badge>
121+
))}
122+
</InlineStack>
123+
);
124+
}

0 commit comments

Comments
 (0)