Skip to content

Commit

Permalink
feat(frontend): enhance GitHub repo picker with search and sorting (#…
Browse files Browse the repository at this point in the history
…5783)

Co-authored-by: openhands <[email protected]>
Co-authored-by: Graham Neubig <[email protected]>
Co-authored-by: sp.wack <[email protected]>
  • Loading branch information
4 people authored Jan 3, 2025
1 parent f14f75b commit 3b26678
Show file tree
Hide file tree
Showing 14 changed files with 221 additions and 58 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils";
import { GitHubRepositorySelector } from "#/components/features/github/github-repo-selector";
import OpenHands from "#/api/open-hands";
import * as GitHubAPI from "#/api/github";

describe("GitHubRepositorySelector", () => {
const onInputChangeMock = vi.fn();
const onSelectMock = vi.fn();

it("should render the search input", () => {
renderWithProviders(
<GitHubRepositorySelector
onInputChange={onInputChangeMock}
onSelect={onSelectMock}
repositories={[]}
/>,
);

expect(
screen.getByPlaceholderText("Select a GitHub project"),
).toBeInTheDocument();
});

it("should show the GitHub login button in OSS mode", () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
getConfigSpy.mockResolvedValue({
APP_MODE: "oss",
APP_SLUG: "openhands",
GITHUB_CLIENT_ID: "test-client-id",
POSTHOG_CLIENT_KEY: "test-posthog-key",
});

renderWithProviders(
<GitHubRepositorySelector
onInputChange={onInputChangeMock}
onSelect={onSelectMock}
repositories={[]}
/>,
);

expect(screen.getByTestId("github-repo-selector")).toBeInTheDocument();
});

it("should show the search results", () => {
const mockSearchedRepos = [
{
id: 1,
full_name: "test/repo1",
stargazers_count: 100,
},
{
id: 2,
full_name: "test/repo2",
stargazers_count: 200,
},
];

const searchPublicRepositoriesSpy = vi.spyOn(
GitHubAPI,
"searchPublicRepositories",
);
searchPublicRepositoriesSpy.mockResolvedValue(mockSearchedRepos);

renderWithProviders(
<GitHubRepositorySelector
onInputChange={onInputChangeMock}
onSelect={onSelectMock}
repositories={[]}
/>,
);

expect(screen.getByTestId("github-repo-selector")).toBeInTheDocument();
});
});
2 changes: 1 addition & 1 deletion frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@
"postcss": "^8.4.47",
"prettier": "^3.4.2",
"tailwindcss": "^3.4.17",
"typescript": "^5.6.3",
"typescript": "^5.7.2",
"vite-plugin-svgr": "^4.2.0",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^1.6.0"
Expand Down
25 changes: 25 additions & 0 deletions frontend/src/api/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,31 @@ export const retrieveGitHubUser = async () => {
return user;
};

export const searchPublicRepositories = async (
query: string,
per_page = 5,
sort: "" | "updated" | "stars" | "forks" = "stars",
order: "desc" | "asc" = "desc",
): Promise<GitHubRepository[]> => {
const sanitizedQuery = query.trim();
if (!sanitizedQuery) {
return [];
}

const response = await github.get<{ items: GitHubRepository[] }>(
"/search/repositories",
{
params: {
q: sanitizedQuery,
per_page,
sort,
order,
},
},
);
return response.data.items;
};

export const retrieveLatestGitHubCommit = async (
repository: string,
): Promise<GitHubCommit | null> => {
Expand Down
77 changes: 38 additions & 39 deletions frontend/src/components/features/github/github-repo-selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,60 +5,49 @@ import posthog from "posthog-js";
import { setSelectedRepository } from "#/state/initial-query-slice";
import { useConfig } from "#/hooks/query/use-config";

interface GitHubRepositoryWithPublic extends GitHubRepository {
is_public?: boolean;
}

interface GitHubRepositorySelectorProps {
onInputChange: (value: string) => void;
onSelect: () => void;
repositories: GitHubRepository[];
repositories: GitHubRepositoryWithPublic[];
}

export function GitHubRepositorySelector({
onInputChange,
onSelect,
repositories,
}: GitHubRepositorySelectorProps) {
const { data: config } = useConfig();
const [selectedKey, setSelectedKey] = React.useState<string | null>(null);

// Add option to install app onto more repos
const finalRepositories =
config?.APP_MODE === "saas"
? [{ id: -1000, full_name: "Add more repositories..." }, ...repositories]
: repositories;

const dispatch = useDispatch();

const handleRepoSelection = (id: string | null) => {
const repo = finalRepositories.find((r) => r.id.toString() === id);
if (id === "-1000") {
if (config?.APP_SLUG)
window.open(
`https://github.com/apps/${config.APP_SLUG}/installations/new`,
"_blank",
);
} else if (repo) {
// set query param
dispatch(setSelectedRepository(repo.full_name));
posthog.capture("repository_selected");
onSelect();
setSelectedKey(id);
const repo = repositories.find((r) => r.id.toString() === id);
if (!repo) return;

if (repo.id === -1000) {
window.open(
`https://github.com/apps/${config?.APP_SLUG}/installations/new`,
"_blank",
);
return;
}

dispatch(setSelectedRepository(repo.full_name));
posthog.capture("repository_selected");
onSelect();
setSelectedKey(id);
};

const handleClearSelection = () => {
// clear query param
dispatch(setSelectedRepository(null));
};

const emptyContent = config?.APP_SLUG ? (
<a
href={`https://github.com/apps/${config.APP_SLUG}/installations/new`}
target="_blank"
rel="noreferrer noopener"
className="underline"
>
Add more repositories...
</a>
) : (
"No results found."
);
const emptyContent = "No results found.";

return (
<Autocomplete
Expand All @@ -67,27 +56,37 @@ export function GitHubRepositorySelector({
aria-label="GitHub Repository"
placeholder="Select a GitHub project"
selectedKey={selectedKey}
items={repositories}
inputProps={{
classNames: {
inputWrapper:
"text-sm w-full rounded-[4px] px-3 py-[10px] bg-[#525252] text-[#A3A3A3]",
},
}}
onSelectionChange={(id) => handleRepoSelection(id?.toString() ?? null)}
clearButtonProps={{ onClick: handleClearSelection }}
onInputChange={onInputChange}
clearButtonProps={{ onPress: handleClearSelection }}
listboxProps={{
emptyContent,
}}
>
{finalRepositories.map((repo) => (
{(item) => (
<AutocompleteItem
data-testid="github-repo-item"
key={repo.id}
value={repo.id}
key={item.id}
value={item.id}
textValue={item.full_name}
>
{repo.full_name}
<div className="flex items-center justify-between">
{item.full_name}
{item.is_public && !!item.stargazers_count && (
<span className="text-xs text-gray-400">
({item.stargazers_count}⭐)
</span>
)}
</div>
</AutocompleteItem>
))}
)}
</Autocomplete>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,54 @@ import { ModalButton } from "#/components/shared/buttons/modal-button";
import { ConnectToGitHubModal } from "#/components/shared/modals/connect-to-github-modal";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { isGitHubErrorReponse } from "#/api/github-axios-instance";
import { useAppRepositories } from "#/hooks/query/use-app-repositories";
import { useSearchRepositories } from "#/hooks/query/use-search-repositories";
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
import { sanitizeQuery } from "#/utils/sanitize-query";
import { useDebounce } from "#/hooks/use-debounce";
import { useConfig } from "#/hooks/query/use-config";

interface GitHubRepositoriesSuggestionBoxProps {
handleSubmit: () => void;
repositories: GitHubRepository[];
gitHubAuthUrl: string | null;
user: GitHubErrorReponse | GitHubUser | null;
}

export function GitHubRepositoriesSuggestionBox({
handleSubmit,
repositories,
gitHubAuthUrl,
user,
}: GitHubRepositoriesSuggestionBoxProps) {
const [connectToGitHubModalOpen, setConnectToGitHubModalOpen] =
React.useState(false);
const [searchQuery, setSearchQuery] = React.useState<string>("");
const debouncedSearchQuery = useDebounce(searchQuery, 300);

const { data: config } = useConfig();
// TODO: Use `useQueries` to fetch all repositories in parallel
const { data: appRepositories } = useAppRepositories();
const { data: userRepositories } = useUserRepositories();
const { data: searchedRepos } = useSearchRepositories(
sanitizeQuery(debouncedSearchQuery),
);

const saasPlaceholderRepository = React.useMemo(() => {
if (config?.APP_MODE === "saas" && config?.APP_SLUG) {
return [
{
id: -1000,
full_name: "Add more repositories...",
},
];
}

return [];
}, [config]);

const repositories =
userRepositories?.pages.flatMap((page) => page.data) ||
appRepositories?.pages.flatMap((page) => page.data) ||
[];

const handleConnectToGitHub = () => {
if (gitHubAuthUrl) {
Expand All @@ -40,8 +72,13 @@ export function GitHubRepositoriesSuggestionBox({
content={
isLoggedIn ? (
<GitHubRepositorySelector
onInputChange={setSearchQuery}
onSelect={handleSubmit}
repositories={repositories}
repositories={[
...saasPlaceholderRepository,
...searchedRepos,
...repositories,
]}
/>
) : (
<ModalButton
Expand Down
4 changes: 1 addition & 3 deletions frontend/src/context/settings-up-to-date-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,7 @@ export function SettingsUpToDateProvider({
);

return (
<SettingsUpToDateContext.Provider value={value}>
{children}
</SettingsUpToDateContext.Provider>
<SettingsUpToDateContext value={value}>{children}</SettingsUpToDateContext>
);
}

Expand Down
12 changes: 12 additions & 0 deletions frontend/src/hooks/query/use-search-repositories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { useQuery } from "@tanstack/react-query";
import { searchPublicRepositories } from "#/api/github";

export function useSearchRepositories(query: string) {
return useQuery({
queryKey: ["repositories", query],
queryFn: () => searchPublicRepositories(query, 3),
enabled: !!query,
select: (data) => data.map((repo) => ({ ...repo, is_public: true })),
initialData: [],
});
}
1 change: 1 addition & 0 deletions frontend/src/hooks/use-click-outside-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const useClickOutsideElement = <T extends HTMLElement>(
};

document.addEventListener("click", handleClickOutside);

return () => document.removeEventListener("click", handleClickOutside);
}, []);

Expand Down
12 changes: 12 additions & 0 deletions frontend/src/hooks/use-debounce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { useEffect, useState } from "react";

export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);

useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);

return debouncedValue;
}
Loading

0 comments on commit 3b26678

Please sign in to comment.