Skip to content
Open
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
143 changes: 143 additions & 0 deletions src/components/shared/CreatedByFilter/CreatedByFilter.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<CreatedByFilter value={undefined} onChange={vi.fn()} />);

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

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

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

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

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",
);
});
});
});
84 changes: 84 additions & 0 deletions src/components/shared/CreatedByFilter/CreatedByFilter.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement>) => {
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>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -73,6 +74,13 @@ export function PipelineRunFiltersBar() {
)}
</div>

<div className="shrink-0">
<CreatedByFilter
value={filters.created_by}
onChange={(value) => setFilter("created_by", value)}
/>
</div>

<div className="shrink-0">
<StatusFilterSelect
value={filters.status}
Expand Down
2 changes: 2 additions & 0 deletions src/utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export const PRIVACY_POLICY_URL =
export const DOCUMENTATION_URL =
import.meta.env.VITE_DOCUMENTATION_URL || "https://tangleml.com/docs/";

export const DEFAULT_CREATED_BY_ME_FILTER_VALUE = "me";

export const API_URL = import.meta.env.VITE_BACKEND_API_URL || "";
export const BASE_URL = import.meta.env.VITE_BASE_URL || "/";
export const IS_GITHUB_PAGES = import.meta.env.VITE_GITHUB_PAGES === "true";
Expand Down