feat(ui): migrate Settings/RouterSettings/Fallbacks/Fallbacks.tsx to shadcn

- @tremor Icon/Table*/+ antd Tooltip/Typography + @heroicons
  ArrowRightIcon/PlayIcon/TrashIcon \u2192 shadcn Table/Tooltip + lucide
  ArrowRight/Play/Trash2.
- Action buttons (Test fallback, Delete fallback) are now real <button>s
  with semantic aria-labels and shadcn Tooltip wrappers.
- Empty-state uses semantic muted/border tokens.

Test updates:
- Fallbacks.test.tsx: `name: /delete/i` \u2192 `name: 'Delete'` (exact
  match) so the modal's confirm button doesn't collide with the row's
  'Delete fallback' aria-label.
- FallbackSelectionForm.test.tsx: assert against the new '+ Add group'
  Button + the inline X button per tab (aria-label='Remove <label>')
  instead of antd's editable-card 'add tab' / 'remove' tab roles.

Gates: TS \u2713 | Vitest 42/42 \u2713 | Build \u2713.

Co-authored-by: yuneng-jiang <yuneng-berri@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-04-23 09:33:07 +00:00
parent f474bde283
commit 6451e87872
No known key found for this signature in database
3 changed files with 101 additions and 64 deletions

View file

@ -109,7 +109,7 @@ describe("FallbackSelectionForm", () => {
/>,
);
const addTabButton = screen.getByRole("button", { name: /add tab/i });
const addTabButton = screen.getByRole("button", { name: /add group/i });
await user.click(addTabButton);
expect(mockOnGroupsChange).toHaveBeenCalledTimes(1);
@ -138,7 +138,10 @@ describe("FallbackSelectionForm", () => {
maxGroups={5}
/>,
);
expect(screen.queryByRole("button", { name: /add tab/i })).not.toBeInTheDocument();
// Post phase-1: 'add tab' is now an explicit '+ Add group' Button.
expect(
screen.queryByRole("button", { name: /add group/i }),
).not.toBeInTheDocument();
});
it("should show add tab button when below maxGroups with custom maxGroups", () => {
@ -153,12 +156,13 @@ describe("FallbackSelectionForm", () => {
maxGroups={3}
/>,
);
expect(screen.getByRole("button", { name: /add tab/i })).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /add group/i }),
).toBeInTheDocument();
});
it("should call onGroupsChange when a group is removed", async () => {
const user = userEvent.setup();
const antd = await import("antd");
const groups: FallbackGroup[] = [
{ id: "1", primaryModel: "gpt-4", fallbackModels: [] },
{ id: "2", primaryModel: "gpt-3.5-turbo", fallbackModels: [] },
@ -171,14 +175,15 @@ describe("FallbackSelectionForm", () => {
/>,
);
const removeButtons = screen.getAllByRole("tab", { name: "remove" });
await user.click(removeButtons[0]);
// Post phase-1: each TabsTrigger has an inline X button with
// aria-label="Remove <label>".
const removeFirst = screen.getByLabelText("Remove gpt-4");
await user.click(removeFirst);
expect(mockOnGroupsChange).toHaveBeenCalledTimes(1);
const [newGroups] = mockOnGroupsChange.mock.calls[0];
expect(newGroups).toHaveLength(1);
expect(newGroups[0].id).toBe("2");
expect(antd.message.warning).not.toHaveBeenCalled();
});
it("should render FallbackGroupConfig for each group", () => {

View file

@ -201,7 +201,7 @@ describe("Fallbacks", () => {
expect(screen.getByTestId("delete-modal")).toBeInTheDocument();
});
const confirmButton = screen.getByRole("button", { name: /delete/i });
const confirmButton = screen.getByRole("button", { name: "Delete" });
await user.click(confirmButton);
await waitFor(() => {
@ -256,7 +256,7 @@ describe("Fallbacks", () => {
expect(screen.getByTestId("delete-modal")).toBeInTheDocument();
});
const confirmButton = screen.getByRole("button", { name: /delete/i });
const confirmButton = screen.getByRole("button", { name: "Delete" });
await user.click(confirmButton);
await waitFor(() => {
@ -283,7 +283,7 @@ describe("Fallbacks", () => {
expect(screen.getByTestId("delete-modal")).toBeInTheDocument();
});
const confirmButton = screen.getByRole("button", { name: /delete/i });
const confirmButton = screen.getByRole("button", { name: "Delete" });
await user.click(confirmButton);
await waitFor(() => {

View file

@ -1,7 +1,19 @@
import { useModelCostMap } from "@/app/(dashboard)/hooks/models/useModelCostMap";
import { ArrowRightIcon, PlayIcon, TrashIcon } from "@heroicons/react/outline";
import { Icon, Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from "@tremor/react";
import { Tooltip, Typography } from "antd";
import { ArrowRight, Play, Trash2 } from "lucide-react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import openai from "openai";
import React, { useEffect, useState } from "react";
import DeleteResourceModal from "../../../common_components/DeleteResourceModal";
@ -14,7 +26,7 @@ type FallbackEntry = { [modelName: string]: string[] };
type Fallbacks = FallbackEntry[];
const modelCardClass =
"inline-flex items-center gap-2 px-2.5 py-1 rounded-md border border-gray-200 bg-gray-50 text-sm font-medium text-gray-800 shrink-0";
"inline-flex items-center gap-2 px-2.5 py-1 rounded-md border border-border bg-muted text-sm font-medium text-foreground shrink-0";
function renderModelNameCell(
modelName: string,
@ -49,16 +61,16 @@ function renderFallbacksChain(
return (
<span className="grid grid-cols-[auto_1fr] items-start gap-x-2 w-full min-w-0">
<span
className="inline-flex items-center justify-center w-8 h-8 shrink-0 self-start text-blue-600"
className="inline-flex items-center justify-center w-8 h-8 shrink-0 self-start text-primary"
aria-hidden
>
<ArrowRightIcon className="w-5 h-5 stroke-[2.5]" />
<ArrowRight className="w-5 h-5 stroke-[2.5]" />
</span>
<span className="flex flex-wrap items-start gap-1 min-w-0">
{list.map((model, i) => (
<React.Fragment key={model}>
{i > 0 && (
<Icon icon={ArrowRightIcon} size="xs" className="shrink-0 text-gray-400" />
<ArrowRight className="w-3 h-3 shrink-0 text-muted-foreground" />
)}
<ChainCard modelName={model} />
</React.Fragment>
@ -253,60 +265,80 @@ const Fallbacks: React.FC<FallbacksProps> = ({ accessToken, userRole, userID, mo
onChange={handleFallbacksChange}
/>
{!hasFallbacks ? (
<div className="rounded-lg border border-gray-200 bg-gray-50 px-4 py-6 text-center">
<Typography.Text type="secondary">
No fallbacks configured. Add fallbacks to automatically try another model when the primary
fails.
</Typography.Text>
<div className="rounded-lg border border-border bg-muted px-4 py-6 text-center">
<span className="text-muted-foreground text-sm">
No fallbacks configured. Add fallbacks to automatically try another
model when the primary fails.
</span>
</div>
) : (
<Table>
<TableHead>
<TableHeader>
<TableRow>
<TableHeaderCell>Model Name</TableHeaderCell>
<TableHeaderCell>Fallbacks</TableHeaderCell>
<TableHeaderCell>Actions</TableHeaderCell>
<TableHead>Model Name</TableHead>
<TableHead>Fallbacks</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHead>
</TableHeader>
<TableBody>
{routerSettings["fallbacks"].map((item: FallbackEntry, index: number) =>
Object.entries(item).map(([key, value]) => (
<TableRow key={index.toString() + key}>
<TableCell className="align-top">
{renderModelNameCell(key, getProviderFromModel)}
</TableCell>
<TableCell className="align-top">
{renderFallbacksChain(key, Array.isArray(value) ? value : [], getProviderFromModel)}
</TableCell>
<TableCell className="align-top">
<Tooltip title="Test fallback">
<Icon
icon={PlayIcon}
size="sm"
onClick={() => testFallbackModelResponse(Object.keys(item)[0], accessToken || "")}
className="cursor-pointer hover:text-blue-600"
/>
</Tooltip>
<Tooltip title="Delete fallback">
<span
data-testid="delete-fallback-button"
role="button"
tabIndex={0}
onClick={() => handleDeleteClick(item)}
onKeyDown={(e) => e.key === "Enter" && handleDeleteClick(item)}
className="cursor-pointer inline-flex"
>
<Icon
icon={TrashIcon}
size="sm"
className="hover:text-red-600"
/>
</span>
</Tooltip>
</TableCell>
</TableRow>
)),
{routerSettings["fallbacks"].map(
(item: FallbackEntry, index: number) =>
Object.entries(item).map(([key, value]) => (
<TableRow key={index.toString() + key}>
<TableCell className="align-top">
{renderModelNameCell(key, getProviderFromModel)}
</TableCell>
<TableCell className="align-top">
{renderFallbacksChain(
key,
Array.isArray(value) ? value : [],
getProviderFromModel,
)}
</TableCell>
<TableCell className="align-top">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() =>
testFallbackModelResponse(
Object.keys(item)[0],
accessToken || "",
)
}
className="cursor-pointer hover:text-primary inline-flex p-1"
aria-label="Test fallback"
>
<Play className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent>Test fallback</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
data-testid="delete-fallback-button"
onClick={() => handleDeleteClick(item)}
onKeyDown={(e) =>
e.key === "Enter" && handleDeleteClick(item)
}
className="cursor-pointer inline-flex p-1 hover:text-destructive"
aria-label="Delete fallback"
>
<Trash2 className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent>Delete fallback</TooltipContent>
</Tooltip>
</TooltipProvider>
</TableCell>
</TableRow>
)),
)}
</TableBody>
</Table>