Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions src/components/shared/CreatedByFilter/CreatedByFilter.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<CreatedByFilter value={undefined} onChange={vi.fn()} />);

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(<CreatedByFilter value={undefined} onChange={vi.fn()} />);

expect(screen.getByLabelText("Created by me")).toBeInTheDocument();
});

it("should show 'Created by {user}' when value is set", () => {
render(<CreatedByFilter value="john.doe" onChange={vi.fn()} />);

expect(screen.getByLabelText("Created by john.doe")).toBeInTheDocument();
});

it("should have switch unchecked when no value", () => {
render(<CreatedByFilter value={undefined} onChange={vi.fn()} />);

expect(screen.getByRole("switch")).not.toBeChecked();
});

it("should have switch checked when value is set", () => {
render(<CreatedByFilter value="me" onChange={vi.fn()} />);

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(<CreatedByFilter value={undefined} onChange={onChange} />);

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(<CreatedByFilter value="me" onChange={onChange} />);

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(<CreatedByFilter value="john.doe" onChange={onChange} />);

// 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(<CreatedByFilter value={undefined} onChange={vi.fn()} />);

expect(screen.getByRole("button", { name: "Search" })).toBeDisabled();
});

it("should enable search button when input has text", async () => {
const user = userEvent.setup();
render(<CreatedByFilter value={undefined} onChange={vi.fn()} />);

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(<CreatedByFilter value={undefined} onChange={onChange} />);

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(<CreatedByFilter value={undefined} onChange={onChange} />);

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(<CreatedByFilter value={undefined} onChange={onChange} />);

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(<CreatedByFilter value={undefined} onChange={onChange} />);

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(<CreatedByFilter value="existing-user" onChange={vi.fn()} />);

expect(screen.getByPlaceholderText("Search by user")).toHaveValue(
"existing-user",
);
});
});
});
82 changes: 82 additions & 0 deletions src/components/shared/CreatedByFilter/CreatedByFilter.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<InlineStack gap="4" blockAlign="center">
<InlineStack gap="2" blockAlign="center">
<Switch
id="created-by-filter"
checked={isFilterActive}
onCheckedChange={handleToggleChange}
/>
<Label htmlFor="created-by-filter">{toggleText}</Label>
</InlineStack>
<InlineStack gap="1" blockAlign="center" wrap="nowrap">
<Input
placeholder="Search by user"
value={searchUser}
onChange={(e) => setSearchUser(e.target.value)}
onKeyDown={handleKeyDown}
className="w-40"
/>
<Button
variant="outline"
size="sm"
onClick={handleUserSearch}
disabled={!searchUser.trim()}
>
Search
</Button>
</InlineStack>
</InlineStack>
);
}