mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
feat(frontend): enhance GitHub repo picker with search and sorting (#5783)
Co-authored-by: openhands <openhands@all-hands.dev> Co-authored-by: Graham Neubig <neubig@gmail.com> Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
This commit is contained in:
parent
f14f75b064
commit
3b26678a77
@ -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
frontend/package-lock.json
generated
2
frontend/package-lock.json
generated
@ -83,7 +83,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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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> => {
|
||||
|
||||
@ -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
|
||||
@ -67,6 +56,7 @@ export function GitHubRepositorySelector({
|
||||
aria-label="GitHub Repository"
|
||||
placeholder="Select a GitHub project"
|
||||
selectedKey={selectedKey}
|
||||
items={repositories}
|
||||
inputProps={{
|
||||
classNames: {
|
||||
inputWrapper:
|
||||
@ -74,20 +64,29 @@ export function GitHubRepositorySelector({
|
||||
},
|
||||
}}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
@ -40,8 +72,13 @@ export function GitHubRepositoriesSuggestionBox({
|
||||
content={
|
||||
isLoggedIn ? (
|
||||
<GitHubRepositorySelector
|
||||
onInputChange={setSearchQuery}
|
||||
onSelect={handleSubmit}
|
||||
repositories={repositories}
|
||||
repositories={[
|
||||
...saasPlaceholderRepository,
|
||||
...searchedRepos,
|
||||
...repositories,
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<ModalButton
|
||||
|
||||
@ -25,9 +25,7 @@ export function SettingsUpToDateProvider({
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsUpToDateContext.Provider value={value}>
|
||||
{children}
|
||||
</SettingsUpToDateContext.Provider>
|
||||
<SettingsUpToDateContext value={value}>{children}</SettingsUpToDateContext>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
12
frontend/src/hooks/query/use-search-repositories.ts
Normal file
12
frontend/src/hooks/query/use-search-repositories.ts
Normal file
@ -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: [],
|
||||
});
|
||||
}
|
||||
@ -17,6 +17,7 @@ export const useClickOutsideElement = <T extends HTMLElement>(
|
||||
};
|
||||
|
||||
document.addEventListener("click", handleClickOutside);
|
||||
|
||||
return () => document.removeEventListener("click", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
|
||||
12
frontend/src/hooks/use-debounce.ts
Normal file
12
frontend/src/hooks/use-debounce.ts
Normal file
@ -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;
|
||||
}
|
||||
@ -3,9 +3,6 @@ import { useDispatch } from "react-redux";
|
||||
import posthog from "posthog-js";
|
||||
import { setImportedProjectZip } from "#/state/initial-query-slice";
|
||||
import { convertZipToBase64 } from "#/utils/convert-zip-to-base64";
|
||||
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
|
||||
import { useAppRepositories } from "#/hooks/query/use-app-repositories";
|
||||
|
||||
import { useGitHubUser } from "#/hooks/query/use-github-user";
|
||||
import { useGitHubAuthUrl } from "#/hooks/use-github-auth-url";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
@ -22,8 +19,6 @@ function Home() {
|
||||
|
||||
const { data: config } = useConfig();
|
||||
const { data: user } = useGitHubUser();
|
||||
const { data: appRepositories } = useAppRepositories();
|
||||
const { data: userRepositories } = useUserRepositories();
|
||||
|
||||
const gitHubAuthUrl = useGitHubAuthUrl({
|
||||
gitHubToken,
|
||||
@ -47,11 +42,6 @@ function Home() {
|
||||
<div className="flex gap-4 w-full flex-col md:flex-row">
|
||||
<GitHubRepositoriesSuggestionBox
|
||||
handleSubmit={() => formRef.current?.requestSubmit()}
|
||||
repositories={
|
||||
userRepositories?.pages.flatMap((page) => page.data) ||
|
||||
appRepositories?.pages.flatMap((page) => page.data) ||
|
||||
[]
|
||||
}
|
||||
gitHubAuthUrl={gitHubAuthUrl}
|
||||
user={user || null}
|
||||
/>
|
||||
|
||||
1
frontend/src/types/github.d.ts
vendored
1
frontend/src/types/github.d.ts
vendored
@ -16,6 +16,7 @@ interface GitHubUser {
|
||||
interface GitHubRepository {
|
||||
id: number;
|
||||
full_name: string;
|
||||
stargazers_count?: number;
|
||||
}
|
||||
|
||||
interface GitHubAppRepository {
|
||||
|
||||
6
frontend/src/utils/sanitize-query.ts
Normal file
6
frontend/src/utils/sanitize-query.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export const sanitizeQuery = (query: string) =>
|
||||
query
|
||||
.replace(/https?:\/\//, "")
|
||||
.replace(/github.com\//, "")
|
||||
.replace(/\.git$/, "")
|
||||
.toLowerCase();
|
||||
@ -68,7 +68,13 @@ export function renderWithProviders(
|
||||
<Provider store={store}>
|
||||
<AuthProvider>
|
||||
<SettingsUpToDateProvider>
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<QueryClientProvider
|
||||
client={
|
||||
new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
})
|
||||
}
|
||||
>
|
||||
<ConversationProvider>
|
||||
<I18nextProvider i18n={i18n}>{children}</I18nextProvider>
|
||||
</ConversationProvider>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user