Skip to content
56 changes: 56 additions & 0 deletions src/components/server-catalog/CatalogResults.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,15 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { StatusIndicator } from "@/components/ui/status-indicator";
import type { CatalogServer } from "@/generated/types";
import { useDebouncedValue } from "@/hooks/useDebouncedValue";
import { getAuthTypeGroupId, getAuthTypeGroupLabelId } from "@/utils/catalogAuthTypes";
import { getTagLabels } from "@/utils/tags";

const EMPTY_PENDING_IDS: ReadonlySet<string> = new Set();
const EMPTY_OAUTH_STATUSES: Readonly<Record<string, OAuthGatewayStatus>> = {};
const EMPTY_ADD_ERRORS: Readonly<Record<string, string>> = {};

function getOAuthCardState(server: CatalogServer, status?: OAuthGatewayStatus) {
const tokenState = status?.user_token_status?.status;
Expand Down Expand Up @@ -77,6 +79,8 @@ function CatalogCard({
canTest,
canDisconnect,
oauthStatuses,
addError,
onAddErrorRead,
}: {
server: CatalogServer;
onView: (trigger: HTMLElement) => void;
Expand All @@ -90,13 +94,18 @@ function CatalogCard({
canTest: boolean;
canDisconnect: boolean;
oauthStatuses?: Readonly<Record<string, OAuthGatewayStatus>>;
/** Why the last add attempt failed, resolved to the server's reason or the fallback. */
addError?: string;
/** Called once the reason has been read, which is what retires the error. */
onAddErrorRead?: () => void;
}) {
const intl = useIntl();
const headingId = useId();
const addTriggerRef = useRef<HTMLButtonElement | null>(null);
const actionsTriggerRef = useRef<HTMLButtonElement | null>(null);
const pendingDetailsTriggerRef = useRef<HTMLButtonElement | null>(null);
const shouldTransferAddFocusRef = useRef(false);
const shouldRestoreErrorFocusRef = useRef(false);
const oauthState = getOAuthCardState(
server,
server.gateway_id ? oauthStatuses?.[server.gateway_id] : undefined,
Expand All @@ -117,6 +126,21 @@ function CatalogCard({
}
}, [isAdding, server.is_registered]);

// Retiring the error unmounts the trigger the popover returns focus to.
useEffect(() => {
if (addError || !shouldRestoreErrorFocusRef.current) return;
shouldRestoreErrorFocusRef.current = false;

const timeoutId = window.setTimeout(() => {
const trigger = addTriggerRef.current;
if (!trigger) return;
const { activeElement, body } = trigger.ownerDocument;
if (activeElement === null || activeElement === body) trigger.focus();
}, 0);

return () => window.clearTimeout(timeoutId);
}, [addError]);

return (
<li className="min-w-0">
<Card className="h-full min-h-[200px] gap-0 rounded-xl border border-border bg-card p-0 py-0 shadow-none ring-0 transition-colors hover:border-ring dark:hover:border-muted-foreground">
Expand Down Expand Up @@ -278,6 +302,32 @@ function CatalogCard({
<FileText className="size-4 text-muted-foreground" aria-hidden="true" />
</Button>
)}

{!server.is_registered && addError && !isAdding && (
<StatusIndicator
Icon={STATUS_ICON.error}
iconClassName={STATUS_TONE_CLASS.error}
labelClassName="text-foreground"
className="ml-2"
label={intl.formatMessage({ id: "mcpServer.catalog.addFailed.short" })}
fullLabel={intl.formatMessage({ id: "mcpServer.catalog.addFailed.label" })}
triggerAriaLabel={intl.formatMessage(
{ id: "mcpServer.catalog.addFailed.trigger" },
{ name: server.name },
)}
contentAriaLabel={intl.formatMessage(
{ id: "mcpServer.catalog.addFailed.detail" },
{ name: server.name },
)}
onOpenChange={(open) => {
if (open) return;
shouldRestoreErrorFocusRef.current = true;
onAddErrorRead?.();
}}
>
<p className="break-words text-sm text-foreground">{addError}</p>
</StatusIndicator>
)}
</div>
</CardContent>
</article>
Expand Down Expand Up @@ -375,6 +425,8 @@ export function CatalogResults({
canTest,
canDisconnect,
oauthStatuses = EMPTY_OAUTH_STATUSES,
addErrors = EMPTY_ADD_ERRORS,
onAddErrorRead,
}: {
servers: CatalogServer[];
emptyStateMessageId: string;
Expand All @@ -389,6 +441,8 @@ export function CatalogResults({
canTest: boolean;
canDisconnect: boolean;
oauthStatuses?: Readonly<Record<string, OAuthGatewayStatus>>;
addErrors?: Readonly<Record<string, string>>;
onAddErrorRead?: (serverId: string) => void;
}) {
const intl = useIntl();
const announcedCount = useDebouncedValue(servers.length, 300);
Expand Down Expand Up @@ -418,6 +472,8 @@ export function CatalogResults({
canTest={canTest}
canDisconnect={canDisconnect}
oauthStatuses={oauthStatuses}
addError={addErrors[server.id]}
onAddErrorRead={onAddErrorRead && (() => onAddErrorRead(server.id))}
/>
))}
</ul>
Expand Down
81 changes: 35 additions & 46 deletions src/components/servers/ServerStatusIndicator.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useState } from "react";
import { useIntl } from "react-intl";

import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { StatusIndicator } from "@/components/ui/status-indicator";
import { cn } from "@/lib/utils";
import {
getAvailabilityPresentation,
Expand Down Expand Up @@ -61,32 +61,18 @@ export function ServerStatusIndicator({
const fullLabel = intl.formatMessage({ id: presentation.labelId });
const isAbbreviated = compact && presentation.shortLabelId !== presentation.labelId;

const icon = (
<StatusIcon
className={cn("h-3.5 w-3.5 shrink-0", presentation.iconClassName)}
aria-hidden="true"
focusable="false"
/>
);

const content = (
<>
{icon}
<span className="text-muted-foreground" aria-hidden={isAbbreviated || undefined}>
{label}
</span>
{isAbbreviated && <span className="sr-only">{fullLabel}</span>}
</>
);
const layout = "inline-flex items-center gap-1.5 text-xs";
const trigger =
"rounded hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring";

if (!interactive) {
return <span className={cn(layout, className)}>{content}</span>;
}
if (interactive && authorize) {
const layout = "inline-flex items-center gap-1.5 text-xs";
const trigger =
"rounded hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring";
const icon = (
<StatusIcon
className={cn("h-3.5 w-3.5 shrink-0", presentation.iconClassName)}
aria-hidden="true"
focusable="false"
/>
);

if (authorize) {
const runAuthorize = async () => {
setIsAuthorizing(true);
try {
Expand Down Expand Up @@ -119,25 +105,28 @@ export function ServerStatusIndicator({
}

return (
<Popover>
<PopoverTrigger
type="button"
aria-label={intl.formatMessage(
{ id: "mcpServer.status.trigger" },
{ name: server.name, status: fullLabel },
)}
className={cn(layout, trigger, className)}
>
{content}
</PopoverTrigger>
<PopoverContent align="end" className="w-auto max-w-xs p-3">
<ServerStatusDetail
availability={availability}
enabled={server.enabled}
lastSeen={server.lastSeen}
lastError={server.lastError}
/>
</PopoverContent>
</Popover>
<StatusIndicator
Icon={StatusIcon}
iconClassName={presentation.iconClassName}
label={label}
fullLabel={isAbbreviated ? fullLabel : undefined}
triggerAriaLabel={intl.formatMessage(
{ id: "mcpServer.status.trigger" },
{ name: server.name, status: fullLabel },
)}
contentAriaLabel={intl.formatMessage(
{ id: "mcpServer.status.detail.label" },
{ name: server.name },
)}
interactive={interactive}
className={className}
>
<ServerStatusDetail
availability={availability}
enabled={server.enabled}
lastSeen={server.lastSeen}
lastError={server.lastError}
/>
</StatusIndicator>
);
}
92 changes: 92 additions & 0 deletions src/components/ui/status-indicator.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { describe, expect, it } from "vitest";
import userEvent from "@testing-library/user-event";
import { OctagonXIcon } from "lucide-react";

import { renderWithProviders, screen } from "@/test/test-utils";
import { StatusIndicator } from "./status-indicator";

const props = {
Icon: OctagonXIcon,
iconClassName: "text-destructive",
label: "Error",
};

describe("StatusIndicator", () => {
it("opens its popover on click", async () => {
const user = userEvent.setup();
renderWithProviders(
<StatusIndicator {...props} triggerAriaLabel="Show details">
<p>Unable to add this server.</p>
</StatusIndicator>,
);

await user.click(screen.getByRole("button", { name: "Show details" }));

expect(await screen.findByText("Unable to add this server.")).toBeVisible();
});

it("names the popover it opens", async () => {
const user = userEvent.setup();
renderWithProviders(
<StatusIndicator
{...props}
triggerAriaLabel="Show details"
contentAriaLabel="Acme add failure details"
>
<p>Unable to add this server.</p>
</StatusIndicator>,
);

await user.click(screen.getByRole("button", { name: "Show details" }));

expect(await screen.findByRole("dialog", { name: "Acme add failure details" })).toBeVisible();
});

it("announces the full label where the visible one is abbreviated", () => {
renderWithProviders(
<StatusIndicator {...props} fullLabel="Error adding server" triggerAriaLabel="Show details">
<p>Detail</p>
</StatusIndicator>,
);

expect(screen.getByText("Error")).toHaveAttribute("aria-hidden", "true");
expect(screen.getByText("Error adding server")).toBeInTheDocument();
});

it("leaves the label announced where it is not abbreviated", () => {
renderWithProviders(
<StatusIndicator {...props} fullLabel="Error" triggerAriaLabel="Show details">
<p>Detail</p>
</StatusIndicator>,
);

expect(screen.getByText("Error")).not.toHaveAttribute("aria-hidden");
});

it("mutes the label unless the caller overrides it", () => {
const { rerender } = renderWithProviders(<StatusIndicator {...props} />);

expect(screen.getByText("Error")).toHaveClass("text-muted-foreground");

rerender(<StatusIndicator {...props} labelClassName="text-foreground" />);

expect(screen.getByText("Error")).toHaveClass("text-foreground");
});

it("renders as plain text where a button cannot nest", () => {
renderWithProviders(
<StatusIndicator {...props} interactive={false}>
<p>Detail</p>
</StatusIndicator>,
);

expect(screen.queryByRole("button")).not.toBeInTheDocument();
expect(screen.getByText("Error")).toBeInTheDocument();
});

it("renders as plain text with nothing to explain", () => {
renderWithProviders(<StatusIndicator {...props} />);

expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
});
Loading
Loading