From 00c449d4472526ad4a4aa5c84994c0b7d6b25f3e Mon Sep 17 00:00:00 2001 From: Robert Brennan Date: Wed, 23 Apr 2025 12:27:49 -0400 Subject: [PATCH] Add loading indicator to repository dropdown on home page (#8015) Co-authored-by: openhands --- .../features/home/repo-connector.test.tsx | 21 ++- .../home/repo-selection-form.test.tsx | 138 ++++++++++++++++++ .../features/home/repo-selection-form.tsx | 91 ++++++++++-- frontend/src/i18n/declaration.ts | 2 + frontend/src/i18n/translation.json | 30 ++++ 5 files changed, 269 insertions(+), 13 deletions(-) create mode 100644 frontend/src/components/features/home/repo-selection-form.test.tsx diff --git a/frontend/__tests__/components/features/home/repo-connector.test.tsx b/frontend/__tests__/components/features/home/repo-connector.test.tsx index 898570161e..04ae0640fa 100644 --- a/frontend/__tests__/components/features/home/repo-connector.test.tsx +++ b/frontend/__tests__/components/features/home/repo-connector.test.tsx @@ -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")); diff --git a/frontend/src/components/features/home/repo-selection-form.test.tsx b/frontend/src/components/features/home/repo-selection-form.test.tsx new file mode 100644 index 0000000000..1f6634dd73 --- /dev/null +++ b/frontend/src/components/features/home/repo-selection-form.test.tsx @@ -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(); + + // 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(); + + // 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(); + + // Check if error message is displayed + expect(screen.getByTestId("repo-dropdown-error")).toBeInTheDocument(); + expect( + screen.getByText("HOME$FAILED_TO_LOAD_REPOSITORIES"), + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/features/home/repo-selection-form.tsx b/frontend/src/components/features/home/repo-selection-form.tsx index 997878979a..514f9fe55e 100644 --- a/frontend/src/components/features/home/repo-selection-form.tsx +++ b/frontend/src/components/features/home/repo-selection-form.tsx @@ -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 ( +
+ + {t("HOME$LOADING_REPOSITORIES")} +
+ ); +} + +// Error state component +function RepositoryErrorState() { + const { t } = useTranslation(); + return ( +
+ {t("HOME$FAILED_TO_LOAD_REPOSITORIES")} +
+ ); +} + +// 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 ( + + ); +} + export function RepositorySelectionForm({ onRepoSelection, }: RepositorySelectionFormProps) { const [selectedRepository, setSelectedRepository] = React.useState(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 ( - <> - { + if (isLoadingRepositories) { + return ; + } + + if (isRepositoriesError) { + return ; + } + + return ( + + ); + }; + + return ( + <> + {renderRepositorySelector()} createConversation({ selectedRepository })} > {!isCreatingConversation && "Launch"} diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index 1128f99eff..f2c8b00511 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -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", diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index 876572504b..e33d5e8e79 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -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": "オープンな課題",