Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(frontend): enhance GitHub repo picker with search and sorting #5783

Open
wants to merge 37 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
62fae43
feat(frontend): enhance GitHub repo picker with search and sorting
openhands-agent Dec 24, 2024
43e643b
fix: add stargazers_count to GitHubRepository type
openhands-agent Dec 24, 2024
e56b09e
fix: use GitHub API directly and improve star count display
openhands-agent Dec 24, 2024
e2a82de
refactor: move GitHub API logic and improve repo picker UI
openhands-agent Dec 24, 2024
d9fdd4a
fix: improve repo picker filtering and selection behavior
openhands-agent Dec 24, 2024
e866df7
fix: improve repo filtering and search relevance
openhands-agent Dec 24, 2024
ef864ef
fix: handle undefined query in repo filter
openhands-agent Dec 24, 2024
d32db88
fix: update NextUI Autocomplete integration
openhands-agent Dec 24, 2024
8a8a227
fix: revert to original array mapping pattern for NextUI Autocomplete
openhands-agent Dec 24, 2024
2e8553e
fix: use defaultFilter instead of filter for NextUI Autocomplete
openhands-agent Dec 24, 2024
9d5cc02
actually working
rbren Dec 24, 2024
c579ddf
fix first item
rbren Dec 24, 2024
980cd4d
fix array
rbren Dec 24, 2024
b799cbb
delint
rbren Dec 24, 2024
6bb99ce
delint
rbren Dec 24, 2024
89adac2
delint
rbren Dec 24, 2024
830c8b7
Fix pr #5783: feat(frontend): enhance GitHub repo picker with search …
openhands-agent Dec 26, 2024
b2b7537
Fix linting and formatting issues in github-repo-selector.tsx
openhands-agent Dec 26, 2024
3d7a83a
Update frontend/src/components/features/github/github-repo-selector.tsx
neubig Dec 26, 2024
4f95435
Fix pr #5783: feat(frontend): enhance GitHub repo picker with search …
openhands-agent Dec 26, 2024
1b8a8db
delint
rbren Dec 26, 2024
09eca76
use sections
rbren Dec 26, 2024
1b41f90
allow full repo urls
rbren Dec 26, 2024
fc87611
delint
rbren Dec 26, 2024
e11630c
Fix: Update model-selector test with improved debugging and simplifie…
openhands-agent Dec 27, 2024
ff83e22
Merge main and resolve conflicts in model-selector test
openhands-agent Dec 27, 2024
aab5ecb
Fix TypeScript errors in model-selector.test.tsx
openhands-agent Dec 27, 2024
4e883f7
refactor: Improve repo search with TanStack Query and clean up tests
openhands-agent Dec 29, 2024
652dfbc
fix: Add onModelChange prop to ModelSelector
openhands-agent Dec 29, 2024
9b012da
fix: Pass empty string instead of null to onModelChange
openhands-agent Dec 29, 2024
e91abf4
Merge branch 'main' into enhance-repo-picker
neubig Dec 29, 2024
c4a1bf9
test: Mock useClickOutsideElement with ref and remove event listener
openhands-agent Dec 30, 2024
7e6b8a2
test: Fix TypeScript errors in tests
openhands-agent Dec 30, 2024
dc09c70
Fix pr #5783: feat(frontend): enhance GitHub repo picker with search …
openhands-agent Dec 30, 2024
2801221
Fix pr #5783: feat(frontend): enhance GitHub repo picker with search …
openhands-agent Dec 30, 2024
21ddf7c
Merge branch 'main' into enhance-repo-picker
neubig Dec 30, 2024
58ee68f
Fix pr #5783: feat(frontend): enhance GitHub repo picker with search …
openhands-agent Dec 30, 2024
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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a way to figure out why these changes were made? To be honest I'm not convinced that implementing a mock for useClickOutsideElement is necessary, but I may be missing some context.

Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,27 @@ import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, test, vi } from "vitest";
import { AccountSettingsContextMenu } from "#/components/features/context-menu/account-settings-context-menu";

const mockRef = { current: null as HTMLElement | null };
let clickListener: ((event: MouseEvent) => void) | null = null;

vi.mock("#/hooks/use-click-outside-element", () => ({
useClickOutsideElement: (callback: () => void) => {
const handleClickOutside = (event: MouseEvent) => {
if (event.target === document.body && mockRef.current) {
callback();
}
};

if (clickListener) {
document.removeEventListener("click", clickListener);
}
clickListener = handleClickOutside;
document.addEventListener("click", handleClickOutside);

return mockRef;
},
}));

describe("AccountSettingsContextMenu", () => {
const user = userEvent.setup();
const onClickAccountSettingsMock = vi.fn();
Expand All @@ -13,6 +34,11 @@ describe("AccountSettingsContextMenu", () => {
onClickAccountSettingsMock.mockClear();
onLogoutMock.mockClear();
onCloseMock.mockClear();
mockRef.current = null;
if (clickListener) {
document.removeEventListener("click", clickListener);
clickListener = null;
}
});

it("should always render the right options", () => {
Expand Down Expand Up @@ -90,8 +116,8 @@ describe("AccountSettingsContextMenu", () => {
/>,
);

const accountSettingsButton = screen.getByText("Account Settings");
await user.click(accountSettingsButton);
const element = screen.getByTestId("account-settings-context-menu");
mockRef.current = element;
await user.click(document.body);

expect(onCloseMock).toHaveBeenCalledOnce();
Expand Down
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A little overkill here. We have the msw mocks to handle this for us. The query/mutation hooks will execute normally given the request we intercepted and mocked with msw. If we want to change the response return type, we can instead mock the request functions directly (e.g., mock the functions passed into queryFn or mutationFn that the hook uses)

Trying to mock the return type and logic of TanStack Query hooks can become complicated real fast.

Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { useConfig } from "#/hooks/query/use-config";
import { useSearchRepositories } from "#/hooks/query/use-search-repositories";
import { GitHubRepositorySelector } from "#/components/features/github/github-repo-selector";
import { Provider } from "react-redux";
import { configureStore } from "@reduxjs/toolkit";
import initialQueryReducer from "#/state/initial-query-slice";

vi.mock("#/hooks/query/use-config");
vi.mock("#/hooks/query/use-search-repositories");

const store = configureStore({
reducer: {
initialQuery: initialQueryReducer,
},
});

describe("GitHubRepositorySelector", () => {
const user = userEvent.setup();
const onSelectMock = vi.fn();

const mockConfig = {
APP_MODE: "saas" as const,
APP_SLUG: "openhands",
GITHUB_CLIENT_ID: "test-client-id",
POSTHOG_CLIENT_KEY: "test-posthog-key",
};

const mockSearchedRepos = [
{
id: 1,
full_name: "test/repo1",
stargazers_count: 100,
},
{
id: 2,
full_name: "test/repo2",
stargazers_count: 200,
},
];

const mockQueryResult = {
data: mockConfig,
error: null,
isError: false as const,
isPending: false as const,
isLoading: false as const,
isFetching: false as const,
isSuccess: true as const,
status: "success" as const,
refetch: vi.fn(),
isLoadingError: false as const,
isRefetchError: false as const,
dataUpdatedAt: Date.now(),
errorUpdatedAt: Date.now(),
failureCount: 0,
failureReason: null,
errorUpdateCount: 0,
isInitialLoading: false as const,
isPlaceholderData: false as const,
isPreviousData: false as const,
isRefetching: false as const,
isStale: false as const,
remove: vi.fn(),
isFetched: true as const,
isFetchedAfterMount: true as const,
isPaused: false as const,
fetchStatus: "idle" as const,
promise: Promise.resolve(mockConfig),
};

const mockSearchResult = {
...mockQueryResult,
data: [],
promise: Promise.resolve([]),
};

it("should render the search input", () => {
vi.mocked(useConfig).mockReturnValue(mockQueryResult);
vi.mocked(useSearchRepositories).mockReturnValue(mockSearchResult);

render(
<Provider store={store}>
<GitHubRepositorySelector onSelect={onSelectMock} repositories={[]} />
</Provider>,
);

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

it("should show the GitHub login button in OSS mode", () => {
const ossConfig = { ...mockConfig, APP_MODE: "oss" as const };
vi.mocked(useConfig).mockReturnValue({
...mockQueryResult,
data: ossConfig,
promise: Promise.resolve(ossConfig),
});
vi.mocked(useSearchRepositories).mockReturnValue(mockSearchResult);

render(
<Provider store={store}>
<GitHubRepositorySelector onSelect={onSelectMock} repositories={[]} />
</Provider>,
);

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

it("should show the search results", () => {
vi.mocked(useConfig).mockReturnValue(mockQueryResult);
vi.mocked(useSearchRepositories).mockReturnValue({
...mockQueryResult,
data: mockSearchedRepos,
promise: Promise.resolve(mockSearchedRepos),
});

render(
<Provider store={store}>
<GitHubRepositorySelector onSelect={onSelectMock} repositories={[]} />
</Provider>,
);

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 @@ -109,7 +109,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> => {
Expand Down
113 changes: 74 additions & 39 deletions frontend/src/components/features/github/github-repo-selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,61 +4,80 @@ import { useDispatch } from "react-redux";
import posthog from "posthog-js";
import { setSelectedRepository } from "#/state/initial-query-slice";
import { useConfig } from "#/hooks/query/use-config";
import { useDebounce } from "#/hooks/use-debounce";
import { useSearchRepositories } from "#/hooks/query/use-search-repositories";

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

function sanitizeQuery(query: string) {
let sanitizedQuery = query.replace(/https?:\/\//, "");
sanitizedQuery = sanitizedQuery.replace(/github.com\//, "");
sanitizedQuery = sanitizedQuery.replace(/\.git$/, "");
sanitizedQuery = sanitizedQuery.toLowerCase();
return sanitizedQuery;
}

Comment on lines +15 to +22
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
function sanitizeQuery(query: string) {
let sanitizedQuery = query.replace(/https?:\/\//, "");
sanitizedQuery = sanitizedQuery.replace(/github.com\//, "");
sanitizedQuery = sanitizedQuery.replace(/\.git$/, "");
sanitizedQuery = sanitizedQuery.toLowerCase();
return sanitizedQuery;
}
function sanitizeQuery(query: string) {
return query
.replace(/https?:\/\//, "")
.replace(/github.com\//, "")
.replace(/\.git$/, "")
.toLowerCase();
}

export function GitHubRepositorySelector({
onSelect,
repositories,
}: GitHubRepositorySelectorProps) {
const { data: config } = useConfig();
const [selectedKey, setSelectedKey] = React.useState<string | null>(null);
const [searchQuery, setSearchQuery] = React.useState<string>("");
const debouncedSearchQuery = useDebounce(searchQuery, 300);
const { data: searchedRepos = [] } = useSearchRepositories(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of setting the default here, we can do so within the query itself through the initialData property

See https://tanstack.com/query/latest/docs/framework/react/guides/initial-query-data#using-initialdata-to-prepopulate-a-query

sanitizeQuery(debouncedSearchQuery),
);

// Add option to install app onto more repos
const finalRepositories =
config?.APP_MODE === "saas"
? [{ id: -1000, full_name: "Add more repositories..." }, ...repositories]
: repositories;
const finalRepositories: GitHubRepository[] = [
...(config?.APP_MODE === "saas" && config?.APP_SLUG
? [
{
id: -1000,
full_name: "Add more repositories...",
} as GitHubRepository,
]
: []),
...searchedRepos.filter(
(repo) => !repositories.find((r) => r.id === repo.id),
),
...repositories.filter(
(repo) =>
!debouncedSearchQuery ||
sanitizeQuery(repo.full_name).includes(
sanitizeQuery(debouncedSearchQuery),
),
),
];
Comment on lines +35 to +54
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const finalRepositories: GitHubRepository[] = [
...(config?.APP_MODE === "saas" && config?.APP_SLUG
? [
{
id: -1000,
full_name: "Add more repositories...",
} as GitHubRepository,
]
: []),
...searchedRepos.filter(
(repo) => !repositories.find((r) => r.id === repo.id),
),
...repositories.filter(
(repo) =>
!debouncedSearchQuery ||
sanitizeQuery(repo.full_name).includes(
sanitizeQuery(debouncedSearchQuery),
),
),
];
const getSaaSPlaceholderRepository = (): GitHubRepository[] => {
if (config?.APP_MODE === "saas" && config?.APP_SLUG) {
return [
{
id: -1000,
full_name: "Add more repositories...",
} as GitHubRepository,
];
}
return [];
};
const filteredSearchedRepos = searchedRepos.filter(
(repo) => !repositories.some((r) => r.id === repo.id)
);
const filteredRepositories = repositories.filter((repo) => {
return (
!debouncedSearchQuery ||
sanitizeQuery(repo.full_name).includes(sanitizeQuery(debouncedSearchQuery))
);
});
const finalRepositories: GitHubRepository[] = [
...getSaaSPlaceholderRepository(),
...filteredSearchedRepos,
...filteredRepositories,
];


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);
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 +86,43 @@ export function GitHubRepositorySelector({
aria-label="GitHub Repository"
placeholder="Select a GitHub project"
selectedKey={selectedKey}
items={finalRepositories}
inputProps={{
classNames: {
inputWrapper:
"text-sm w-full rounded-[4px] px-3 py-[10px] bg-[#525252] text-[#A3A3A3]",
},
}}
onSelectionChange={(id) => handleRepoSelection(id?.toString() ?? null)}
onInputChange={(value) => setSearchQuery(value)}
clearButtonProps={{ onClick: handleClearSelection }}
listboxProps={{
emptyContent,
}}
defaultFilter={(textValue, inputValue) =>
!inputValue ||
sanitizeQuery(textValue).includes(sanitizeQuery(inputValue))
}
>
{finalRepositories.map((repo) => (
<AutocompleteItem
data-testid="github-repo-item"
key={repo.id}
value={repo.id}
>
{repo.full_name}
</AutocompleteItem>
))}
{(item) => {
const isPublicRepo = !repositories.find((r) => r.id === item.id);
return (
<AutocompleteItem
data-testid="github-repo-item"
key={item.id}
value={item.id}
className="data-[selected=true]:bg-default-100"
textValue={item.full_name}
>
{item.full_name}
{isPublicRepo && item.stargazers_count !== undefined && (
<span className="ml-1 text-gray-400">
({item.stargazers_count}⭐)
</span>
)}
</AutocompleteItem>
);
}}
</Autocomplete>
);
}
Loading
Loading