From 1675966df38e2fd9e40b4d32df293e107f51bdba Mon Sep 17 00:00:00 2001 From: mbeaulne Date: Tue, 27 Jan 2026 13:49:06 -0500 Subject: [PATCH] Add created by me filter --- .../CreatedByFilter/CreatedByFilter.test.tsx | 143 ++++++++++++++++++ .../CreatedByFilter/CreatedByFilter.tsx | 84 ++++++++++ .../PipelineRunFiltersBar.tsx | 8 + src/utils/constants.ts | 2 + 4 files changed, 237 insertions(+) create mode 100644 src/components/shared/CreatedByFilter/CreatedByFilter.test.tsx create mode 100644 src/components/shared/CreatedByFilter/CreatedByFilter.tsx diff --git a/src/components/shared/CreatedByFilter/CreatedByFilter.test.tsx b/src/components/shared/CreatedByFilter/CreatedByFilter.test.tsx new file mode 100644 index 000000000..0f3de1227 --- /dev/null +++ b/src/components/shared/CreatedByFilter/CreatedByFilter.test.tsx @@ -0,0 +1,143 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { DEFAULT_CREATED_BY_ME_FILTER_VALUE } from "@/utils/constants"; + +import { CreatedByFilter } from "./CreatedByFilter"; + +describe("CreatedByFilter", () => { + describe("rendering", () => { + it("should render with toggle unchecked and search input when no value", () => { + render(); + + expect(screen.getByRole("switch")).not.toBeChecked(); + expect(screen.getByLabelText("Created by me")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Search by user")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Search" }), + ).toBeInTheDocument(); + }); + + it("should show 'Created by {user}' and checked switch when value is set", () => { + render(); + + expect(screen.getByRole("switch")).toBeChecked(); + expect(screen.getByLabelText("Created by john.doe")).toBeInTheDocument(); + }); + }); + + describe("toggle behavior", () => { + it("should call onChange with 'me' when toggle is turned on", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + + await user.click(screen.getByRole("switch")); + + expect(onChange).toHaveBeenCalledWith(DEFAULT_CREATED_BY_ME_FILTER_VALUE); + }); + + it("should call onChange with undefined when toggle is turned off", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render( + , + ); + + await user.click(screen.getByRole("switch")); + + expect(onChange).toHaveBeenCalledWith(undefined); + }); + + it("should clear filter when toggling off with existing value", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + + await user.click(screen.getByRole("switch")); + + expect(onChange).toHaveBeenCalledWith(undefined); + }); + }); + + describe("search behavior", () => { + it("should have search button disabled when input is empty", () => { + render(); + + expect(screen.getByRole("button", { name: "Search" })).toBeDisabled(); + }); + + it("should enable search button when input has text", async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByPlaceholderText("Search by user"), "jane"); + + expect(screen.getByRole("button", { name: "Search" })).toBeEnabled(); + }); + + it("should call onChange with typed user when search clicked", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + + await user.type( + screen.getByPlaceholderText("Search by user"), + "jane.doe", + ); + await user.click(screen.getByRole("button", { name: "Search" })); + + expect(onChange).toHaveBeenCalledWith("jane.doe"); + }); + + it("should call onChange when Enter is pressed in input", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + + const input = screen.getByPlaceholderText("Search by user"); + await user.type(input, "jane.doe{Enter}"); + + expect(onChange).toHaveBeenCalledWith("jane.doe"); + }); + + it("should trim whitespace from search input", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + + await user.type( + screen.getByPlaceholderText("Search by user"), + " jane ", + ); + await user.click(screen.getByRole("button", { name: "Search" })); + + expect(onChange).toHaveBeenCalledWith("jane"); + }); + + it("should not call onChange when searching with only whitespace", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + + await user.type(screen.getByPlaceholderText("Search by user"), " "); + + // Button should still be disabled + expect(screen.getByRole("button", { name: "Search" })).toBeDisabled(); + }); + }); + + describe("initial state", () => { + it("should populate search input with current value", () => { + render(); + + expect(screen.getByPlaceholderText("Search by user")).toHaveValue( + "existing-user", + ); + }); + }); +}); diff --git a/src/components/shared/CreatedByFilter/CreatedByFilter.tsx b/src/components/shared/CreatedByFilter/CreatedByFilter.tsx new file mode 100644 index 000000000..46da15c25 --- /dev/null +++ b/src/components/shared/CreatedByFilter/CreatedByFilter.tsx @@ -0,0 +1,84 @@ +import type { KeyboardEvent } from "react"; +import { useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { InlineStack } from "@/components/ui/layout"; +import { Switch } from "@/components/ui/switch"; +import { DEFAULT_CREATED_BY_ME_FILTER_VALUE } from "@/utils/constants"; + +interface CreatedByFilterProps { + /** Current filter value. undefined means no filter, "me" means current user. */ + value: string | undefined; + /** Called when the filter value changes. undefined clears the filter. */ + onChange: (value: string | undefined) => void; +} + +/** + * Filter component for filtering by creator/initiator. + * Provides a toggle for "Created by me" and an input for searching by specific user. + */ +export function CreatedByFilter({ value, onChange }: CreatedByFilterProps) { + const [searchUser, setSearchUser] = useState(value ?? ""); + + const isFilterActive = value !== undefined; + const toggleText = value ? `Created by ${value}` : "Created by me"; + + const handleToggleChange = (checked: boolean) => { + if (checked) { + // Enable filter - if no specific user set, default to "me" + if (!value) { + onChange(DEFAULT_CREATED_BY_ME_FILTER_VALUE); + setSearchUser(""); + } + } else { + // Disable filter + onChange(undefined); + } + }; + + const handleUserSearch = () => { + const trimmedUser = searchUser.trim(); + if (trimmedUser) { + onChange(trimmedUser); + } + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Enter" && searchUser.trim()) { + e.preventDefault(); + handleUserSearch(); + } + }; + + return ( + + + + + + + setSearchUser(e.target.value)} + onKeyDown={handleKeyDown} + className="w-40" + /> + + + + ); +} diff --git a/src/components/shared/PipelineRunFiltersBar/PipelineRunFiltersBar.tsx b/src/components/shared/PipelineRunFiltersBar/PipelineRunFiltersBar.tsx index 79f7e0f6f..80b93233a 100644 --- a/src/components/shared/PipelineRunFiltersBar/PipelineRunFiltersBar.tsx +++ b/src/components/shared/PipelineRunFiltersBar/PipelineRunFiltersBar.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import type { DateRange } from "react-day-picker"; import { AnnotationFilterInput } from "@/components/shared/AnnotationFilterInput/AnnotationFilterInput"; +import { CreatedByFilter } from "@/components/shared/CreatedByFilter/CreatedByFilter"; import { StatusFilterSelect } from "@/components/shared/StatusFilterSelect/StatusFilterSelect"; import { Button } from "@/components/ui/button"; import { DatePickerWithRange } from "@/components/ui/date-picker"; @@ -73,6 +74,13 @@ export function PipelineRunFiltersBar() { )} +
+ setFilter("created_by", value)} + /> +
+