feat(frontend): Allow searching/filtering repositories. (#9791)

This commit is contained in:
Hiep Le
2025-07-21 23:05:32 +07:00
committed by GitHub
parent d0cf12e474
commit bbfa37fd97
5 changed files with 369 additions and 28 deletions

View File

@@ -80,6 +80,22 @@ describe("MicroagentManagement", () => {
owner_type: "user",
pushed_at: "2021-10-04T12:00:00Z",
},
{
id: "5",
full_name: "user/TestRepository",
git_provider: "github",
is_public: true,
owner_type: "user",
pushed_at: "2021-10-05T12:00:00Z",
},
{
id: "6",
full_name: "org/AnotherRepo",
git_provider: "github",
is_public: true,
owner_type: "organization",
pushed_at: "2021-10-06T12:00:00Z",
},
];
const mockMicroagents: RepositoryMicroagent[] = [
@@ -465,4 +481,272 @@ describe("MicroagentManagement", () => {
expect(readyMessage).toBeInTheDocument();
expect(descriptionMessage).toBeInTheDocument();
});
// Search functionality tests
describe("Search functionality", () => {
it("should render search input field", async () => {
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Check that search input is rendered
const searchInput = screen.getByRole("textbox", {
name: /search repositories/i,
});
expect(searchInput).toBeInTheDocument();
expect(searchInput).toHaveAttribute(
"placeholder",
"Search repositories...",
);
});
it("should filter repositories when typing in search input", async () => {
const user = userEvent.setup();
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Initially only repositories with .openhands should be visible
expect(screen.getByText("user/repo2/.openhands")).toBeInTheDocument();
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
expect(screen.queryByText("user/repo4")).not.toBeInTheDocument();
// Type in search input to filter further
const searchInput = screen.getByRole("textbox", {
name: /search repositories/i,
});
await user.type(searchInput, "repo2");
// Only repo2 should be visible
expect(screen.getByText("user/repo2/.openhands")).toBeInTheDocument();
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
expect(
screen.queryByText("org/repo3/.openhands"),
).not.toBeInTheDocument();
expect(screen.queryByText("user/repo4")).not.toBeInTheDocument();
expect(screen.queryByText("user/TestRepository")).not.toBeInTheDocument();
expect(screen.queryByText("org/AnotherRepo")).not.toBeInTheDocument();
});
it("should perform case-insensitive search", async () => {
const user = userEvent.setup();
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Type in search input with uppercase
const searchInput = screen.getByRole("textbox", {
name: /search repositories/i,
});
await user.type(searchInput, "REPO2");
// repo2 should be visible (case-insensitive match)
expect(screen.getByText("user/repo2/.openhands")).toBeInTheDocument();
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
expect(
screen.queryByText("org/repo3/.openhands"),
).not.toBeInTheDocument();
});
it("should filter repositories by partial matches", async () => {
const user = userEvent.setup();
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Type in search input with partial match
const searchInput = screen.getByRole("textbox", {
name: /search repositories/i,
});
await user.type(searchInput, "repo");
// All repositories with "repo" in the name should be visible
expect(screen.getByText("user/repo2/.openhands")).toBeInTheDocument();
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
expect(
screen.queryByText("org/repo3/.openhands"),
).not.toBeInTheDocument();
expect(screen.queryByText("user/repo4")).not.toBeInTheDocument();
expect(screen.queryByText("user/TestRepository")).not.toBeInTheDocument();
expect(screen.queryByText("org/AnotherRepo")).not.toBeInTheDocument();
});
it("should show all repositories when search input is cleared", async () => {
const user = userEvent.setup();
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Type in search input
const searchInput = screen.getByRole("textbox", {
name: /search repositories/i,
});
await user.type(searchInput, "repo2");
// Only repo2 should be visible
expect(screen.getByText("user/repo2/.openhands")).toBeInTheDocument();
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
// Clear the search input
await user.clear(searchInput);
// All repositories should be visible again (only those with .openhands)
expect(screen.getByText("user/repo2/.openhands")).toBeInTheDocument();
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
expect(
screen.queryByText("org/repo3/.openhands"),
).not.toBeInTheDocument();
expect(screen.queryByText("user/repo4")).not.toBeInTheDocument();
expect(screen.queryByText("user/TestRepository")).not.toBeInTheDocument();
expect(screen.queryByText("org/AnotherRepo")).not.toBeInTheDocument();
});
it("should handle empty search results", async () => {
const user = userEvent.setup();
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Type in search input with non-existent repository name
const searchInput = screen.getByRole("textbox", {
name: /search repositories/i,
});
await user.type(searchInput, "nonexistent");
// No repositories should be visible
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
expect(
screen.queryByText("user/repo2/.openhands"),
).not.toBeInTheDocument();
expect(
screen.queryByText("org/repo3/.openhands"),
).not.toBeInTheDocument();
expect(screen.queryByText("user/repo4")).not.toBeInTheDocument();
expect(screen.queryByText("user/TestRepository")).not.toBeInTheDocument();
expect(screen.queryByText("org/AnotherRepo")).not.toBeInTheDocument();
});
it("should handle special characters in search", async () => {
const user = userEvent.setup();
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Type in search input with special characters
const searchInput = screen.getByRole("textbox", {
name: /search repositories/i,
});
await user.type(searchInput, ".openhands");
// Only repositories with .openhands should be visible
expect(screen.getByText("user/repo2/.openhands")).toBeInTheDocument();
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
expect(screen.queryByText("user/repo4")).not.toBeInTheDocument();
});
it("should maintain accordion functionality with filtered results", async () => {
const user = userEvent.setup();
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Filter to show only repo2
const searchInput = screen.getByRole("textbox", {
name: /search repositories/i,
});
await user.type(searchInput, "repo2");
// Click on the filtered repository accordion
const repoAccordion = screen.getByText("user/repo2/.openhands");
await user.click(repoAccordion);
// Wait for microagents to be fetched
await waitFor(() => {
expect(OpenHands.getRepositoryMicroagents).toHaveBeenCalledWith(
"user",
"repo2",
);
});
// Check that microagents are displayed
const microagent1 = screen.getByText("test-microagent-1");
const microagent2 = screen.getByText("test-microagent-2");
expect(microagent1).toBeInTheDocument();
expect(microagent2).toBeInTheDocument();
});
it("should handle whitespace in search input", async () => {
const user = userEvent.setup();
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Type in search input with leading/trailing whitespace
const searchInput = screen.getByRole("textbox", {
name: /search repositories/i,
});
await user.type(searchInput, " repo2 ");
// repo2 should still be visible (whitespace should be trimmed)
expect(screen.getByText("user/repo2/.openhands")).toBeInTheDocument();
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
});
it("should update search results in real-time", async () => {
const user = userEvent.setup();
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
const searchInput = screen.getByRole("textbox", {
name: /search repositories/i,
});
// Type "repo" - should show repo2
await user.type(searchInput, "repo");
expect(screen.getByText("user/repo2/.openhands")).toBeInTheDocument();
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
// Add "2" to make it "repo2" - should show only repo2
await user.type(searchInput, "2");
expect(screen.getByText("user/repo2/.openhands")).toBeInTheDocument();
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
// Remove "2" to make it "repo" again - should show repo2
await user.keyboard("{Backspace}");
expect(screen.getByText("user/repo2/.openhands")).toBeInTheDocument();
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
});
});
});

View File

@@ -14,7 +14,7 @@ export function MicroagentManagementAccordionTitle({
<div className="flex items-center gap-2">
<GitProviderIcon gitProvider={repository.git_provider} />
<div
className="text-white text-base font-normal truncate max-w-[168px]"
className="text-white text-base font-normal truncate max-w-[150px]"
title={repository.full_name}
>
{repository.full_name}

View File

@@ -1,13 +1,15 @@
import { useState, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Accordion, AccordionItem } from "@heroui/react";
import { MicroagentManagementRepoMicroagents } from "./microagent-management-repo-microagents";
import { GitRepository } from "#/types/git";
import { getGitProviderBaseUrl } from "#/utils/utils";
import { getGitProviderBaseUrl, cn } from "#/utils/utils";
import { TabType } from "#/types/microagent-management";
import { MicroagentManagementNoRepositories } from "./microagent-management-no-repositories";
import { I18nKey } from "#/i18n/declaration";
import { DOCUMENTATION_URL } from "#/utils/constants";
import { MicroagentManagementAccordionTitle } from "./microagent-management-accordion-title";
import { sanitizeQuery } from "#/utils/sanitize-query";
type MicroagentManagementRepositoriesProps = {
repositories: GitRepository[];
@@ -19,9 +21,23 @@ export function MicroagentManagementRepositories({
tabType,
}: MicroagentManagementRepositoriesProps) {
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = useState("");
const numberOfRepoMicroagents = repositories.length;
// Filter repositories based on search query
const filteredRepositories = useMemo(() => {
if (!searchQuery.trim()) {
return repositories;
}
const sanitizedQuery = sanitizeQuery(searchQuery);
return repositories.filter((repository) => {
const sanitizedRepoName = sanitizeQuery(repository.full_name);
return sanitizedRepoName.includes(sanitizedQuery);
});
}, [repositories, searchQuery]);
if (numberOfRepoMicroagents === 0) {
if (tabType === "personal") {
return (
@@ -56,30 +72,54 @@ export function MicroagentManagementRepositories({
}
return (
<Accordion
variant="splitted"
className="w-full px-0 gap-3"
itemClasses={{
base: "shadow-none bg-transparent border border-[#ffffff40] rounded-[6px] cursor-pointer",
trigger: "cursor-pointer",
}}
selectionMode="multiple"
>
{repositories.map((repository) => (
<AccordionItem
key={repository.id}
aria-label={repository.full_name}
title={<MicroagentManagementAccordionTitle repository={repository} />}
>
<MicroagentManagementRepoMicroagents
repoMicroagent={{
id: repository.id,
repositoryName: repository.full_name,
repositoryUrl: `${getGitProviderBaseUrl(repository.git_provider)}/${repository.full_name}`,
}}
/>
</AccordionItem>
))}
</Accordion>
<div className="flex flex-col gap-4 w-full">
{/* Search Input */}
<div className="flex flex-col gap-2 w-full">
<label htmlFor="repository-search" className="sr-only">
{t(I18nKey.COMMON$SEARCH_REPOSITORIES)}
</label>
<input
id="repository-search"
name="repository-search"
type="text"
placeholder={`${t(I18nKey.COMMON$SEARCH_REPOSITORIES)}...`}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className={cn(
"bg-tertiary border border-[#717888] bg-[#454545] w-full rounded-sm p-2 placeholder:italic placeholder:text-tertiary-alt",
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
)}
/>
</div>
{/* Repositories Accordion */}
<Accordion
variant="splitted"
className="w-full px-0 gap-3"
itemClasses={{
base: "shadow-none bg-transparent border border-[#ffffff40] rounded-[6px] cursor-pointer",
trigger: "cursor-pointer",
}}
selectionMode="multiple"
>
{filteredRepositories.map((repository) => (
<AccordionItem
key={repository.id}
aria-label={repository.full_name}
title={
<MicroagentManagementAccordionTitle repository={repository} />
}
>
<MicroagentManagementRepoMicroagents
repoMicroagent={{
id: repository.id,
repositoryName: repository.full_name,
repositoryUrl: `${getGitProviderBaseUrl(repository.git_provider)}/${repository.full_name}`,
}}
/>
</AccordionItem>
))}
</Accordion>
</div>
);
}

View File

@@ -711,4 +711,5 @@ export enum I18nKey {
MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_USER_LEVEL_MICROAGENTS = "MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_USER_LEVEL_MICROAGENTS",
MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_MICROAGENTS = "MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_MICROAGENTS",
MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_ORGANIZATION_LEVEL_MICROAGENTS = "MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_ORGANIZATION_LEVEL_MICROAGENTS",
COMMON$SEARCH_REPOSITORIES = "COMMON$SEARCH_REPOSITORIES",
}

View File

@@ -11197,7 +11197,7 @@
"fr": "Que souhaitez-vous que le microagent fasse ?",
"tr": "Mikro ajanın ne yapmasını istersiniz?",
"de": "Was soll der Microagent tun?",
"uk": "Що ви хочете, щоб зробив мікроагент?"
"uk": "Що в,и хочете, щоб зробив мікроагент?"
},
"MICROAGENT_MANAGEMENT$DESCRIBE_WHAT_TO_DO": {
"en": "Describe what you would like the Microagent to do.",
@@ -11374,5 +11374,21 @@
"tr": "Organizasyon düzeyinde mikro ajanınız yok",
"de": "Sie haben keine Mikroagenten auf Organisationsebene",
"uk": "У вас немає мікроагентів на рівні організації"
},
"COMMON$SEARCH_REPOSITORIES": {
"en": "Search repositories",
"ja": "リポジトリを検索",
"zh-CN": "搜索仓库",
"zh-TW": "搜尋存儲庫",
"ko-KR": "저장소 검색",
"no": "Søk i repositories",
"it": "Cerca repository",
"pt": "Pesquisar repositórios",
"es": "Buscar repositorios",
"ar": "البحث في المستودعات",
"fr": "Rechercher des dépôts",
"tr": "Depo ara",
"de": "Repositorys durchsuchen",
"uk": "Пошук репозиторіїв"
}
}