diff --git a/src/components/gateways/ExposeComponentsForm.test.tsx b/src/components/gateways/ExposeComponentsForm.test.tsx index 5027c7b..6ff5513 100644 --- a/src/components/gateways/ExposeComponentsForm.test.tsx +++ b/src/components/gateways/ExposeComponentsForm.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, beforeAll, afterAll, afterEach } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; +import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { http, HttpResponse } from "msw"; import { setupServer } from "msw/node"; @@ -593,4 +593,83 @@ describe("ExposeComponentsForm", () => { }); }); }); + + describe("Failed list loads", () => { + it("should distinguish a failed load from a successful empty list", async () => { + server.use(http.get("/api/tools", () => new HttpResponse(null, { status: 500 }))); + + renderWithProviders(); + + const alert = await screen.findByRole("alert"); + expect(alert).toHaveTextContent("Failed to load tools"); + expect(screen.queryByText("0 tools")).not.toBeInTheDocument(); + // Sections whose own request succeeded keep showing their real counts. + expect(screen.getByText("2 resources")).toBeInTheDocument(); + expect(screen.getByText("3 prompt templates")).toBeInTheDocument(); + }); + + it("should restore the count and clear the error after a successful retry", async () => { + server.use(http.get("/api/tools", () => new HttpResponse(null, { status: 500 }))); + + const user = userEvent.setup(); + renderWithProviders(); + + const alert = await screen.findByRole("alert"); + server.resetHandlers(); + await user.click(within(alert).getByRole("button", { name: "Retry" })); + + await waitFor(() => { + expect(screen.getByText("3 tools")).toBeInTheDocument(); + }); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("should keep healthy sections mounted while a failed section retries", async () => { + server.use(http.get("/api/tools", () => new HttpResponse(null, { status: 500 }))); + + const user = userEvent.setup(); + renderWithProviders(); + + const alert = await screen.findByRole("alert"); + expect(screen.getByText("2 resources")).toBeInTheDocument(); + + // Make the retry hang so the per-section loading state stays observable. + server.use( + http.get("/api/tools", async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + return HttpResponse.json(mockTools); + }), + ); + await user.click(within(alert).getByRole("button", { name: "Retry" })); + + // The tools section shows its own loading row instead of the error... + await waitFor(() => { + expect(screen.getByText("Loading...")).toBeInTheDocument(); + }); + // ...and the sections that already loaded are not replaced by a form-level spinner. + expect(screen.getByText("2 resources")).toBeInTheDocument(); + expect(screen.getByText("3 prompt templates")).toBeInTheDocument(); + expect(screen.queryByRole("status", { name: /loading/i })).not.toBeInTheDocument(); + + await waitFor(() => { + expect(screen.getByText("3 tools")).toBeInTheDocument(); + }); + }); + + it("should show a failed state for each section that failed to load", async () => { + server.use( + http.get("/api/tools", () => new HttpResponse(null, { status: 500 })), + http.get("/api/prompts", () => new HttpResponse(null, { status: 500 })), + ); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("Failed to load tools")).toBeInTheDocument(); + }); + expect(screen.getByText("Failed to load prompt templates")).toBeInTheDocument(); + // The section whose request succeeded keeps showing its real count. + expect(screen.getByText("2 resources")).toBeInTheDocument(); + }); + }); }); diff --git a/src/components/gateways/ExposeComponentsForm.tsx b/src/components/gateways/ExposeComponentsForm.tsx index b6a1937..ec2af0e 100644 --- a/src/components/gateways/ExposeComponentsForm.tsx +++ b/src/components/gateways/ExposeComponentsForm.tsx @@ -1,4 +1,5 @@ import { useState, useMemo, useCallback, useEffect } from "react"; +import { useIntl } from "react-intl"; import { ChevronDown, ChevronRight, @@ -24,6 +25,7 @@ import { useQuery } from "@/hooks/useQuery"; import { Loading } from "@/components/ui/loading"; import { createVirtualServer } from "@/api/virtualServers"; import { InlineNotification } from "@/components/ui/inline-notification"; +import { STATUS_TONE_CLASS } from "@/lib/status"; import { useRouter } from "@/router"; import type { CreateServerDetails } from "@/components/gateways/types"; import type { Visibility } from "@/types/server"; @@ -145,6 +147,7 @@ export function ExposeComponentsForm({ clearFetchToolsNotification, }: ExposeComponentsFormProps) { const { navigate } = useRouter(); + const intl = useIntl(); const [expandedSection, setExpandedSection] = useState("tools"); const [selectedTools, setSelectedTools] = useState>(new Set()); const [selectedResources, setSelectedResources] = useState>(new Set()); @@ -159,16 +162,19 @@ export function ExposeComponentsForm({ // Fetch tools, resources, and prompts for this gateway const { data: toolsData, + error: toolsError, isLoading: toolsLoading, refetch: refetchTools, } = useQuery(`/tools?limit=1000&gateway_id=${gatewayId}`); const { data: resourcesData, + error: resourcesError, isLoading: resourcesLoading, refetch: refetchResources, } = useQuery(`/resources?limit=1000&gateway_id=${gatewayId}`); const { data: promptsData, + error: promptsError, isLoading: promptsLoading, refetch: refetchPrompts, } = useQuery(`/prompts?limit=1000&gateway_id=${gatewayId}`); @@ -194,6 +200,16 @@ export function ExposeComponentsForm({ const promptCount = prompts.length; const isLoading = toolsLoading || resourcesLoading || promptsLoading; + // Only the very first load (before any section has resolved) replaces the whole + // form with a spinner; a per-section retry must keep the healthy sections visible. + const isInitialLoad = + isLoading && + toolsData === undefined && + resourcesData === undefined && + promptsData === undefined && + !toolsError && + !resourcesError && + !promptsError; const toggleSection = useCallback((section: string) => { setExpandedSection((prev) => (prev === section ? null : section)); @@ -275,7 +291,7 @@ export function ExposeComponentsForm({ } }; - if (isLoading) { + if (isInitialLoad) { return (
@@ -352,8 +368,16 @@ export function ExposeComponentsForm({ aria-hidden="true" />
- - {toolCount} {toolCount === 1 ? "tool" : "tools"} + + {toolsLoading + ? intl.formatMessage({ id: "common.loading" }) + : toolsError + ? intl.formatMessage({ id: "gateways.exposeComponents.error.tools" }) + : intl.formatMessage({ id: "gateways.card.toolCount" }, { count: toolCount })}
{expandedSection === "tools" ? ( @@ -369,6 +393,27 @@ export function ExposeComponentsForm({ )} + {toolsError && ( +
+ + refetchTools().catch((err) => console.error("Failed to refetch tools:", err)), + }} + /> +
+ )} + {expandedSection === "tools" && tools.length > 0 && (
- - {resourceCount} {resourceCount === 1 ? "resource" : "resources"} + + {resourcesLoading + ? intl.formatMessage({ id: "common.loading" }) + : resourcesError + ? intl.formatMessage({ id: "gateways.exposeComponents.error.resources" }) + : intl.formatMessage( + { id: "gateways.card.resourceCount" }, + { count: resourceCount }, + )} {expandedSection === "resources" ? ( @@ -416,6 +474,29 @@ export function ExposeComponentsForm({ /> )} + + {resourcesError && ( +
+ + refetchResources().catch((err) => + console.error("Failed to refetch resources:", err), + ), + }} + /> +
+ )} {expandedSection === "resources" && resources.length > 0 && (
- - {promptCount} prompt {promptCount === 1 ? "template" : "templates"} + + {promptsLoading + ? intl.formatMessage({ id: "common.loading" }) + : promptsError + ? intl.formatMessage({ id: "gateways.exposeComponents.error.prompts" }) + : intl.formatMessage( + { id: "gateways.exposeComponents.promptCount" }, + { count: promptCount }, + )} {expandedSection === "prompts" ? ( @@ -463,6 +557,29 @@ export function ExposeComponentsForm({ /> )} + + {promptsError && ( +
+ + refetchPrompts().catch((err) => + console.error("Failed to refetch prompts:", err), + ), + }} + /> +
+ )} {expandedSection === "prompts" && prompts.length > 0 && (