diff --git a/apps/web/components/combobox.tsx b/apps/web/components/combobox.tsx index 6e0ffdaa44..5a1662514c 100644 --- a/apps/web/components/combobox.tsx +++ b/apps/web/components/combobox.tsx @@ -46,6 +46,7 @@ interface ComboboxProps { triggerClassName?: string; showSearch?: boolean; testId?: string; + dropdownTestId?: string; popoverSide?: "top" | "right" | "bottom" | "left"; popoverAlign?: "start" | "center" | "end"; /** When true, the trigger always renders the plain label text instead of renderLabel. */ @@ -147,6 +148,7 @@ export const Combobox = memo(function Combobox({ triggerClassName, showSearch = true, testId, + dropdownTestId, popoverSide, popoverAlign = "start", plainTrigger = false, @@ -201,7 +203,12 @@ export const Combobox = memo(function Combobox({ align={popoverAlign} portal={false} > - + {dropdownLabel || headerAction ? (
{dropdownLabel} diff --git a/apps/web/components/github/my-github/list-toolbar.test.tsx b/apps/web/components/github/my-github/list-toolbar.test.tsx new file mode 100644 index 0000000000..ac251ba017 --- /dev/null +++ b/apps/web/components/github/my-github/list-toolbar.test.tsx @@ -0,0 +1,76 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { TooltipProvider } from "@kandev/ui/tooltip"; +import { ListToolbar } from "./list-toolbar"; + +afterEach(() => cleanup()); + +const ALL_REPOS_LABEL = "All repos"; +const REPO_SEARCH_PLACEHOLDER = "Filter repositories..."; +const REPO_DROPDOWN_TEST_ID = "github-repo-filter-dropdown"; +const KANDEV_REPO = "kdlbs/kandev"; + +function renderToolbar({ repoFilter = "" }: { repoFilter?: string } = {}) { + const onRepoFilterChange = vi.fn(); + render( + + + , + ); + return { onRepoFilterChange }; +} + +describe("ListToolbar", () => { + it("opens the repository filter with a search input", async () => { + renderToolbar(); + + fireEvent.click(screen.getByText(ALL_REPOS_LABEL)); + + expect(await screen.findByPlaceholderText(REPO_SEARCH_PLACEHOLDER)).toBeTruthy(); + expect(screen.getByTestId(REPO_DROPDOWN_TEST_ID)).toBeTruthy(); + expect(screen.getByText(KANDEV_REPO)).toBeTruthy(); + }); + + it("filters and selects a repository", async () => { + const { onRepoFilterChange } = renderToolbar(); + + fireEvent.click(screen.getByText(ALL_REPOS_LABEL)); + fireEvent.change(await screen.findByPlaceholderText(REPO_SEARCH_PLACEHOLDER), { + target: { value: "kdlbs" }, + }); + fireEvent.click(await screen.findByRole("option", { name: KANDEV_REPO })); + + expect(onRepoFilterChange).toHaveBeenCalledWith(KANDEV_REPO); + }); + + it("clears the repository filter from the All repos option", async () => { + const { onRepoFilterChange } = renderToolbar({ repoFilter: KANDEV_REPO }); + + fireEvent.click(screen.getByText(KANDEV_REPO)); + fireEvent.click(await screen.findByRole("option", { name: ALL_REPOS_LABEL })); + + expect(onRepoFilterChange).toHaveBeenCalledWith(""); + }); + + it("preserves the repository filter when the selected repository is reselected", async () => { + const { onRepoFilterChange } = renderToolbar({ repoFilter: KANDEV_REPO }); + + fireEvent.click(screen.getByText(KANDEV_REPO)); + fireEvent.click(await screen.findByRole("option", { name: KANDEV_REPO })); + + expect(onRepoFilterChange).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/components/github/my-github/list-toolbar.tsx b/apps/web/components/github/my-github/list-toolbar.tsx index b27f0b587e..b2e914823c 100644 --- a/apps/web/components/github/my-github/list-toolbar.tsx +++ b/apps/web/components/github/my-github/list-toolbar.tsx @@ -1,9 +1,10 @@ "use client"; +import { useMemo } from "react"; import { IconRefresh } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Input } from "@kandev/ui/input"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; +import { Combobox, type ComboboxOption } from "@/components/combobox"; import { cn } from "@/lib/utils"; import { formatRelativeTime } from "@/lib/utils"; @@ -57,6 +58,13 @@ function RefreshControls({ ); } +function buildRepoFilterOptions(repoOptions: string[]): ComboboxOption[] { + return [ + { value: ALL_REPOS, label: "All repos", keywords: ["all", "repositories", "repos"] }, + ...repoOptions.map((key) => ({ value: key, label: key, keywords: [key] })), + ]; +} + export function ListToolbar({ title, count, @@ -73,6 +81,7 @@ export function ListToolbar({ }: ListToolbarProps) { const selectValue = repoFilter || ALL_REPOS; const dirty = customQuery !== committedQuery; + const repoFilterOptions = useMemo(() => buildRepoFilterOptions(repoOptions), [repoOptions]); return (
@@ -93,24 +102,22 @@ export function ListToolbar({ />
- + onValueChange={(v) => { + // Combobox signals "toggle off" with ""; All repos is the explicit clear path. + if (!v) return; + onRepoFilterChange(v === ALL_REPOS ? "" : v); + }} + options={repoFilterOptions} + placeholder="All repos" + searchPlaceholder="Filter repositories..." + emptyMessage="No repositories found." + triggerClassName="w-full md:w-[220px] h-8 border border-input bg-background hover:bg-secondary/50 px-2 py-1.5 text-xs/relaxed" + className="md:min-w-[360px]" + testId="github-repo-filter-trigger" + dropdownTestId="github-repo-filter-dropdown" + />
{ await scopeBar.getByRole("button", { name: "Issues" }).click(); await expect(title).toContainText("Assigned"); }); + + test("repo filter menu can be searched", async ({ testPage, apiClient }) => { + await apiClient.mockGitHubReset(); + await apiClient.mockGitHubSetUser("test-user"); + await apiClient.mockGitHubAddPRs([ + { + number: 21, + title: "Searchable repo filter PR", + state: "open", + head_branch: "feat/search", + base_branch: "main", + author_login: "test-user", + repo_owner: "testorg", + repo_name: "testrepo", + }, + { + number: 22, + title: "Searchable repo filter second PR", + state: "open", + head_branch: "feat/other", + base_branch: "main", + author_login: "test-user", + repo_owner: "anotherorg", + repo_name: "secondrepo", + }, + ]); + + await testPage.goto("/github"); + + await testPage.getByTestId("github-repo-filter-trigger").click(); + const search = testPage.getByTestId("github-repo-filter-dropdown").getByRole("combobox"); + await expect(search).toBeVisible(); + + await search.fill("testorg"); + await testPage.getByRole("option", { name: "testorg/testrepo" }).click(); + await expect(testPage.getByTestId("github-repo-filter-trigger")).toContainText( + "testorg/testrepo", + ); + }); }); diff --git a/apps/web/e2e/tests/github/mobile-github-sidebar.spec.ts b/apps/web/e2e/tests/github/mobile-github-sidebar.spec.ts index 7e9c7786e5..3a69ec6b25 100644 --- a/apps/web/e2e/tests/github/mobile-github-sidebar.spec.ts +++ b/apps/web/e2e/tests/github/mobile-github-sidebar.spec.ts @@ -45,4 +45,41 @@ test.describe("Mobile /github sidebar", () => { await expect(page.mobileSidebar).toBeHidden(); await expect(page.toolbarTitle).toContainText("Mentions"); }); + + test("repo filter menu can be searched", async ({ testPage, apiClient }) => { + await apiClient.mockGitHubReset(); + await apiClient.mockGitHubSetUser("test-user"); + await apiClient.mockGitHubAddPRs([ + { + number: 210, + title: "Mobile repo search PR", + state: "open", + head_branch: "feat/mobile-search", + base_branch: "main", + author_login: "test-user", + repo_owner: "testorg", + repo_name: "testrepo", + }, + { + number: 211, + title: "Mobile repo search second PR", + state: "open", + head_branch: "feat/mobile-other", + base_branch: "main", + author_login: "test-user", + repo_owner: "anotherorg", + repo_name: "secondrepo", + }, + ]); + + const page = new MobileGitHubPage(testPage); + await page.goto(); + + await page.repoFilterTrigger.tap(); + await expect(page.repoSearchInput).toBeVisible(); + + await page.repoSearchInput.fill("testorg"); + await testPage.getByRole("option", { name: "testorg/testrepo" }).tap(); + await expect(page.repoFilterTrigger).toContainText("testorg/testrepo"); + }); });