Add loading indicator to repository dropdown on home page (#8015)

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Robert Brennan
2025-04-23 12:27:49 -04:00
committed by GitHub
parent bfd75a1355
commit 00c449d447
5 changed files with 269 additions and 13 deletions

View File

@@ -74,7 +74,8 @@ describe("RepoConnector", () => {
renderRepoConnector();
const dropdown = screen.getByTestId("repo-dropdown");
// Wait for the loading state to be replaced with the dropdown
const dropdown = await waitFor(() => screen.getByTestId("repo-dropdown"));
await userEvent.click(dropdown);
await waitFor(() => {
@@ -98,7 +99,8 @@ describe("RepoConnector", () => {
const launchButton = screen.getByTestId("repo-launch-button");
expect(launchButton).toBeDisabled();
const dropdown = screen.getByTestId("repo-dropdown");
// Wait for the loading state to be replaced with the dropdown
const dropdown = await waitFor(() => screen.getByTestId("repo-dropdown"));
await userEvent.click(dropdown);
await userEvent.click(screen.getByText("rbren/polaris"));
@@ -132,6 +134,14 @@ describe("RepoConnector", () => {
it("should create a conversation and redirect with the selected repo when pressing the launch button", async () => {
const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
const retrieveUserGitRepositoriesSpy = vi.spyOn(
GitService,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
data: MOCK_RESPOSITORIES,
nextPage: null,
});
renderRepoConnector();
@@ -144,7 +154,9 @@ describe("RepoConnector", () => {
expect(createConversationSpy).not.toHaveBeenCalled();
// select a repository from the dropdown
const dropdown = within(repoConnector).getByTestId("repo-dropdown");
const dropdown = await waitFor(() =>
within(repoConnector).getByTestId("repo-dropdown")
);
await userEvent.click(dropdown);
const repoOption = screen.getByText("rbren/polaris");
@@ -178,7 +190,8 @@ describe("RepoConnector", () => {
const launchButton = screen.getByTestId("repo-launch-button");
const dropdown = screen.getByTestId("repo-dropdown");
// Wait for the loading state to be replaced with the dropdown
const dropdown = await waitFor(() => screen.getByTestId("repo-dropdown"));
await userEvent.click(dropdown);
await userEvent.click(screen.getByText("rbren/polaris"));

View File

@@ -0,0 +1,138 @@
import { render, screen } from "@testing-library/react";
import { describe, test, expect, vi, beforeEach } from "vitest";
import { RepositorySelectionForm } from "./repo-selection-form";
// Create mock functions
const mockUseUserRepositories = vi.fn();
const mockUseCreateConversation = vi.fn();
const mockUseIsCreatingConversation = vi.fn();
const mockUseTranslation = vi.fn();
const mockUseAuth = vi.fn();
// Setup default mock returns
mockUseUserRepositories.mockReturnValue({
data: { pages: [{ data: [] }] },
isLoading: false,
isError: false,
});
mockUseCreateConversation.mockReturnValue({
mutate: vi.fn(),
isPending: false,
isSuccess: false,
});
mockUseIsCreatingConversation.mockReturnValue(false);
mockUseTranslation.mockReturnValue({ t: (key: string) => key });
mockUseAuth.mockReturnValue({
isAuthenticated: true,
isLoading: false,
providersAreSet: true,
user: {
id: 1,
login: "testuser",
avatar_url: "https://example.com/avatar.png",
name: "Test User",
email: "test@example.com",
company: "Test Company",
},
login: vi.fn(),
logout: vi.fn(),
});
// Mock the modules
vi.mock("#/hooks/query/use-user-repositories", () => ({
useUserRepositories: () => mockUseUserRepositories(),
}));
vi.mock("#/hooks/mutation/use-create-conversation", () => ({
useCreateConversation: () => mockUseCreateConversation(),
}));
vi.mock("#/hooks/use-is-creating-conversation", () => ({
useIsCreatingConversation: () => mockUseIsCreatingConversation(),
}));
vi.mock("react-i18next", () => ({
useTranslation: () => mockUseTranslation(),
}));
vi.mock("#/context/auth-context", () => ({
useAuth: () => mockUseAuth(),
}));
describe("RepositorySelectionForm", () => {
const mockOnRepoSelection = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
test("shows loading indicator when repositories are being fetched", () => {
// Setup loading state
mockUseUserRepositories.mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
});
render(<RepositorySelectionForm onRepoSelection={mockOnRepoSelection} />);
// Check if loading indicator is displayed
expect(screen.getByTestId("repo-dropdown-loading")).toBeInTheDocument();
expect(screen.getByText("HOME$LOADING_REPOSITORIES")).toBeInTheDocument();
});
test("shows dropdown when repositories are loaded", () => {
// Setup loaded repositories
mockUseUserRepositories.mockReturnValue({
data: {
pages: [
{
data: [
{
id: 1,
full_name: "user/repo1",
git_provider: "github",
is_public: true,
},
{
id: 2,
full_name: "user/repo2",
git_provider: "github",
is_public: true,
},
],
},
],
},
isLoading: false,
isError: false,
});
render(<RepositorySelectionForm onRepoSelection={mockOnRepoSelection} />);
// Check if dropdown is displayed
expect(screen.getByTestId("repo-dropdown")).toBeInTheDocument();
});
test("shows error message when repository fetch fails", () => {
// Setup error state
mockUseUserRepositories.mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
error: new Error("Failed to fetch repositories"),
});
render(<RepositorySelectionForm onRepoSelection={mockOnRepoSelection} />);
// Check if error message is displayed
expect(screen.getByTestId("repo-dropdown-error")).toBeInTheDocument();
expect(
screen.getByText("HOME$FAILED_TO_LOAD_REPOSITORIES"),
).toBeInTheDocument();
});
});

View File

@@ -1,5 +1,6 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Spinner } from "@heroui/react";
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation";
@@ -11,12 +12,68 @@ interface RepositorySelectionFormProps {
onRepoSelection: (repoTitle: string | null) => void;
}
// Loading state component
function RepositoryLoadingState() {
const { t } = useTranslation();
return (
<div
data-testid="repo-dropdown-loading"
className="flex items-center gap-2 max-w-[500px] h-10 px-3 bg-tertiary border border-[#717888] rounded"
>
<Spinner size="sm" />
<span className="text-sm">{t("HOME$LOADING_REPOSITORIES")}</span>
</div>
);
}
// Error state component
function RepositoryErrorState() {
const { t } = useTranslation();
return (
<div
data-testid="repo-dropdown-error"
className="flex items-center gap-2 max-w-[500px] h-10 px-3 bg-tertiary border border-[#717888] rounded text-red-500"
>
<span className="text-sm">{t("HOME$FAILED_TO_LOAD_REPOSITORIES")}</span>
</div>
);
}
// Repository dropdown component
interface RepositoryDropdownProps {
items: { key: React.Key; label: string }[];
onSelectionChange: (key: React.Key | null) => void;
onInputChange: (value: string) => void;
}
function RepositoryDropdown({
items,
onSelectionChange,
onInputChange,
}: RepositoryDropdownProps) {
return (
<SettingsDropdownInput
testId="repo-dropdown"
name="repo-dropdown"
placeholder="Select a repo"
items={items}
wrapperClassName="max-w-[500px]"
onSelectionChange={onSelectionChange}
onInputChange={onInputChange}
/>
);
}
export function RepositorySelectionForm({
onRepoSelection,
}: RepositorySelectionFormProps) {
const [selectedRepository, setSelectedRepository] =
React.useState<GitRepository | null>(null);
const { data: repositories } = useUserRepositories();
const {
data: repositories,
isLoading: isLoadingRepositories,
isError: isRepositoriesError,
} = useUserRepositories();
const {
mutate: createConversation,
isPending,
@@ -52,23 +109,39 @@ export function RepositorySelectionForm({
}
};
return (
<>
<SettingsDropdownInput
testId="repo-dropdown"
name="repo-dropdown"
placeholder="Select a repo"
// Render the appropriate UI based on the loading/error state
const renderRepositorySelector = () => {
if (isLoadingRepositories) {
return <RepositoryLoadingState />;
}
if (isRepositoriesError) {
return <RepositoryErrorState />;
}
return (
<RepositoryDropdown
items={repositoriesItems || []}
wrapperClassName="max-w-[500px]"
onSelectionChange={handleRepoSelection}
onInputChange={handleInputChange}
/>
);
};
return (
<>
{renderRepositorySelector()}
<BrandButton
testId="repo-launch-button"
variant="primary"
type="button"
isDisabled={!selectedRepository || isCreatingConversation}
isDisabled={
!selectedRepository ||
isCreatingConversation ||
isLoadingRepositories ||
isRepositoriesError
}
onClick={() => createConversation({ selectedRepository })}
>
{!isCreatingConversation && "Launch"}

View File

@@ -6,6 +6,8 @@ export enum I18nKey {
HOME$NOT_SURE_HOW_TO_START = "HOME$NOT_SURE_HOW_TO_START",
HOME$CONNECT_TO_REPOSITORY = "HOME$CONNECT_TO_REPOSITORY",
HOME$LOADING = "HOME$LOADING",
HOME$LOADING_REPOSITORIES = "HOME$LOADING_REPOSITORIES",
HOME$FAILED_TO_LOAD_REPOSITORIES = "HOME$FAILED_TO_LOAD_REPOSITORIES",
HOME$OPEN_ISSUE = "HOME$OPEN_ISSUE",
HOME$FIX_FAILING_CHECKS = "HOME$FIX_FAILING_CHECKS",
HOME$RESOLVE_MERGE_CONFLICTS = "HOME$RESOLVE_MERGE_CONFLICTS",

View File

@@ -89,6 +89,36 @@
"tr": "Yükleniyor...",
"de": "Wird geladen..."
},
"HOME$LOADING_REPOSITORIES": {
"en": "Loading repositories...",
"ja": "リポジトリを読み込み中...",
"zh-CN": "加载仓库中...",
"zh-TW": "載入儲存庫中...",
"ko-KR": "저장소 로딩 중...",
"no": "Laster repositories...",
"it": "Caricamento repository in corso...",
"pt": "Carregando repositórios...",
"es": "Cargando repositorios...",
"ar": "جار تحميل المستودعات...",
"fr": "Chargement des dépôts...",
"tr": "Depolar yükleniyor...",
"de": "Repositories werden geladen..."
},
"HOME$FAILED_TO_LOAD_REPOSITORIES": {
"en": "Failed to load repositories",
"ja": "リポジトリの読み込みに失敗しました",
"zh-CN": "加载仓库失败",
"zh-TW": "載入儲存庫失敗",
"ko-KR": "저장소 로딩 실패",
"no": "Kunne ikke laste repositories",
"it": "Impossibile caricare i repository",
"pt": "Falha ao carregar repositórios",
"es": "Error al cargar repositorios",
"ar": "فشل في تحميل المستودعات",
"fr": "Échec du chargement des dépôts",
"tr": "Depolar yüklenemedi",
"de": "Fehler beim Laden der Repositories"
},
"HOME$OPEN_ISSUE": {
"en": "Open issue",
"ja": "オープンな課題",