From 08c0320e4e8f26d3bcdcabf41cb71a5e84d28d0d 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 | 146 ++++++++++++++++++ .../CreatedByFilter/CreatedByFilter.tsx | 82 ++++++++++ 2 files changed, 228 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..035b3666d --- /dev/null +++ b/src/components/shared/CreatedByFilter/CreatedByFilter.test.tsx @@ -0,0 +1,146 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { CreatedByFilter } from "./CreatedByFilter"; + +describe("CreatedByFilter", () => { + describe("rendering", () => { + it("should render toggle and search input", () => { + render(); + + expect(screen.getByRole("switch")).toBeInTheDocument(); + 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 me' when no value", () => { + render(); + + expect(screen.getByLabelText("Created by me")).toBeInTheDocument(); + }); + + it("should show 'Created by {user}' when value is set", () => { + render(); + + expect(screen.getByLabelText("Created by john.doe")).toBeInTheDocument(); + }); + + it("should have switch unchecked when no value", () => { + render(); + + expect(screen.getByRole("switch")).not.toBeChecked(); + }); + + it("should have switch checked when value is set", () => { + render(); + + expect(screen.getByRole("switch")).toBeChecked(); + }); + }); + + 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("me"); + }); + + 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 not change value when toggle on with existing value", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + + // Toggle is already on, clicking it should turn it off + 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..dc0acd38a --- /dev/null +++ b/src/components/shared/CreatedByFilter/CreatedByFilter.tsx @@ -0,0 +1,82 @@ +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"; + +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("me"); + setSearchUser(""); + } + } else { + // Disable filter + onChange(undefined); + } + }; + + const handleUserSearch = () => { + const trimmedUser = searchUser.trim(); + if (trimmedUser) { + onChange(trimmedUser); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && searchUser.trim()) { + e.preventDefault(); + handleUserSearch(); + } + }; + + return ( + + + + + + + setSearchUser(e.target.value)} + onKeyDown={handleKeyDown} + className="w-40" + /> + + + + ); +}