refactor(ui): migrate ten small dashboard files off antd and tremor

Moves the onboarding views, router settings inputs, tag rate limit editor,
fallback buttons, created-key display and the shared numerical input onto the
in-repo shadcn layer. Each control has a direct equivalent, so this is a
like-for-like swap with no layout changes and no new styling.

Router settings saves by reading input values straight off the DOM with
document.querySelector('input[name="..."]'), a path no test covered. Adds a
regression test that types into a field and asserts the typed value reaches
the payload, so the name attribute contract stays enforced.

Also adds tests for TagRateLimitEditor, which had none and whose RPM cell
switched from antd InputNumber to a native number input.
This commit is contained in:
Yuneng Jiang 2026-08-14 11:15:30 -07:00
parent c9917cbf99
commit aa093980b1
No known key found for this signature in database
16 changed files with 225 additions and 97 deletions

View file

@ -1667,21 +1667,11 @@
"count": 1
}
},
"src/app/onboarding/OnboardingErrorView.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/onboarding/OnboardingFormBody.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/onboarding/OnboardingLoadingView.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/AIHub/ModelHubTable.test.tsx": {
"max-params": {
"count": 1
@ -1884,9 +1874,6 @@
}
},
"src/components/Settings/RouterSettings/Fallbacks/AddFallbacks.tsx": {
"no-restricted-imports": {
"count": 2
},
"react-hooks/set-state-in-effect": {
"count": 1
}
@ -2425,11 +2412,6 @@
"count": 1
}
},
"src/components/key_team_helpers/TagRateLimitEditor.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/key_team_helpers/fetch_available_models_team_key.tsx": {
"local/filename-pascal-case": {
"count": 1
@ -2761,17 +2743,9 @@
"count": 1
}
},
"src/components/router_settings/LatencyBasedConfiguration.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/router_settings/ReliabilityRetriesSection.tsx": {
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"src/components/router_settings/RoutingStrategySelector.tsx": {
@ -2779,18 +2753,10 @@
"count": 1
}
},
"src/components/router_settings/TagFilteringToggle.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/router_settings/index.tsx": {
"local/filename-pascal-case": {
"count": 1
},
"no-restricted-imports": {
"count": 1
},
"prefer-const": {
"count": 2
}
@ -2832,11 +2798,6 @@
"count": 4
}
},
"src/components/shared/CreatedKeyDisplay.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/shared/advanced_date_picker.tsx": {
"local/filename-pascal-case": {
"count": 1
@ -2901,9 +2862,6 @@
"src/components/shared/numerical_input.tsx": {
"local/filename-pascal-case": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"src/components/shared/table_cells/cell_tooltip.tsx": {

View file

@ -9,6 +9,11 @@ describe("OnboardingErrorView", () => {
expect(screen.getByText("Failed to load invitation")).toBeInTheDocument();
});
it("should expose the failure as an alert to assistive technology", () => {
render(<OnboardingErrorView />);
expect(screen.getByRole("alert")).toHaveTextContent("Failed to load invitation");
});
it("should show the expiry description", () => {
render(<OnboardingErrorView />);
expect(screen.getByText("The invitation link may be invalid or expired.")).toBeInTheDocument();
@ -16,7 +21,6 @@ describe("OnboardingErrorView", () => {
it("should render a Back to Login link pointing to /ui/login/", () => {
render(<OnboardingErrorView />);
// antd Button with href renders as an <a> element
const link = screen.getByRole("link", { name: "Back to Login" });
expect(link).toHaveAttribute("href", "/ui/login/");
});

View file

@ -1,18 +1,21 @@
import React from "react";
import { Alert, Button } from "antd";
import { CircleAlert } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
import { buttonVariants } from "@/components/ui/button";
import { getLoginUrl } from "@/utils/returnUrlUtils";
export function OnboardingErrorView() {
return (
<div className="mx-auto w-full max-w-md mt-10">
<Alert
type="error"
message="Failed to load invitation"
description="The invitation link may be invalid or expired."
showIcon
/>
<Alert variant="error">
<CircleAlert />
<AlertTitle>Failed to load invitation</AlertTitle>
<AlertDescription>The invitation link may be invalid or expired.</AlertDescription>
</Alert>
<div className="mt-4">
<Button href={getLoginUrl()}>Back to Login</Button>
<a href={getLoginUrl()} className={buttonVariants({ variant: "outline" })}>
Back to Login
</a>
</div>
</div>
);

View file

@ -1,12 +1,12 @@
import React from "react";
import { render } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { OnboardingLoadingView } from "./OnboardingLoadingView";
describe("OnboardingLoadingView", () => {
it("should render a spinner container", () => {
const { container } = render(<OnboardingLoadingView />);
expect(container.firstChild).toBeInTheDocument();
it("should expose the loading state to assistive technology", () => {
render(<OnboardingLoadingView />);
expect(screen.getByRole("status", { name: "Loading invitation" })).toBeInTheDocument();
});
it("should apply centering layout classes", () => {

View file

@ -1,11 +1,10 @@
import React from "react";
import { Spin } from "antd";
import { LoadingOutlined } from "@ant-design/icons";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
export function OnboardingLoadingView() {
return (
<div className="mx-auto w-full max-w-md mt-10 flex justify-center">
<Spin indicator={<LoadingOutlined spin />} size="large" />
<UiLoadingSpinner role="status" aria-label="Loading invitation" className="size-8 text-muted-foreground" />
</div>
);
}

View file

@ -4,9 +4,9 @@
* Works with forms - reads from and writes to router_settings.fallbacks
*/
import { Button as TremorButton } from "@tremor/react";
import { Button } from "antd";
import React, { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import MessageManager from "@/components/molecules/message_manager";
import NotificationManager from "../../../molecules/notifications_manager";
import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models";
@ -119,13 +119,10 @@ export default function AddFallbacks({ accessToken, value = [], onChange }: AddF
return (
<div>
<TremorButton
className="mx-auto"
onClick={() => setIsModalVisible(true)}
icon={() => <span className="mr-1">+</span>}
>
<Button className="mx-auto" onClick={() => setIsModalVisible(true)}>
<span>+</span>
Add Fallbacks
</TremorButton>
</Button>
<AddFallbacksModal open={isModalVisible} onCancel={handleCancel}>
<FallbackSelectionForm
key={modalKey}
@ -138,15 +135,11 @@ export default function AddFallbacks({ accessToken, value = [], onChange }: AddF
{/* Footer with Cancel and Save buttons */}
{groups.length > 0 && (
<div className="flex items-center justify-end space-x-3 pt-6 mt-6 border-t border-gray-100">
<Button type="default" onClick={handleCancel} disabled={isSaving}>
<Button variant="outline" onClick={handleCancel} disabled={isSaving}>
Cancel
</Button>
<Button
type="default"
onClick={handleSaveAll}
disabled={groups.length === 0 || isSaving}
loading={isSaving}
>
<Button variant="outline" onClick={handleSaveAll} disabled={groups.length === 0 || isSaving}>
{isSaving && <UiLoadingSpinner className="size-4" />}
{isSaving ? "Saving Configuration..." : "Save All Configurations"}
</Button>
</div>

View file

@ -0,0 +1,133 @@
import React, { useState } from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect } from "vitest";
import { TagRateLimitEditor, TagRateLimitEntry, tagLimitsToRows, tagRowsToLimits } from "./TagRateLimitEditor";
// The editor is controlled, so multi-character typing only behaves realistically
// when the parent feeds each change back in.
function Harness({ initial = [] as TagRateLimitEntry[], onValue }: { initial?: TagRateLimitEntry[]; onValue?: any }) {
const [rows, setRows] = useState<TagRateLimitEntry[]>(initial);
return (
<TagRateLimitEditor
value={rows}
onChange={(next) => {
setRows(next);
onValue?.(next);
}}
/>
);
}
const rowsWith = (tag: string, rpm: number | null): TagRateLimitEntry[] => [{ id: "r1", tag, rpm_limit: rpm }];
describe("TagRateLimitEditor", () => {
it("should render one tag and one RPM field per row", () => {
render(<Harness initial={rowsWith("cell-1", 100)} />);
expect(screen.getByRole("textbox", { name: "Tag" })).toHaveValue("cell-1");
expect(screen.getByRole("spinbutton", { name: "RPM limit" })).toHaveValue(100);
});
it("should add a row when Add Tag Limit is clicked", async () => {
const user = userEvent.setup();
render(<Harness />);
expect(screen.queryAllByRole("textbox", { name: "Tag" })).toHaveLength(0);
await user.click(screen.getByRole("button", { name: /add tag limit/i }));
expect(screen.getAllByRole("textbox", { name: "Tag" })).toHaveLength(1);
});
it("should let the user type a tag name", async () => {
const user = userEvent.setup();
render(<Harness initial={rowsWith("", null)} />);
await user.type(screen.getByRole("textbox", { name: "Tag" }), "cell-2");
expect(screen.getByRole("textbox", { name: "Tag" })).toHaveValue("cell-2");
});
// The RPM cell feeds tagRowsToLimits, which drops any entry whose limit is not
// typeof "number". A string would silently discard the user's limit.
it("should record the typed RPM limit as a number, not a string", async () => {
const user = userEvent.setup();
const seen: TagRateLimitEntry[][] = [];
render(<Harness initial={rowsWith("cell-1", null)} onValue={(v: TagRateLimitEntry[]) => seen.push(v)} />);
await user.type(screen.getByRole("spinbutton", { name: "RPM limit" }), "60");
const latest = seen[seen.length - 1][0];
expect(latest.rpm_limit).toBe(60);
expect(typeof latest.rpm_limit).toBe("number");
});
it("should reset the RPM limit to null when the field is cleared", async () => {
const user = userEvent.setup();
const seen: TagRateLimitEntry[][] = [];
render(<Harness initial={rowsWith("cell-1", 60)} onValue={(v: TagRateLimitEntry[]) => seen.push(v)} />);
await user.clear(screen.getByRole("spinbutton", { name: "RPM limit" }));
expect(seen[seen.length - 1][0].rpm_limit).toBeNull();
});
it("should remove only the clicked row", async () => {
const user = userEvent.setup();
const initial: TagRateLimitEntry[] = [
{ id: "r1", tag: "keep-me", rpm_limit: 10 },
{ id: "r2", tag: "delete-me", rpm_limit: 20 },
];
render(<Harness initial={initial} />);
await user.click(screen.getAllByRole("button", { name: "Remove tag limit" })[1]);
const tags = screen.getAllByRole("textbox", { name: "Tag" });
expect(tags).toHaveLength(1);
expect(tags[0]).toHaveValue("keep-me");
});
it("should not submit the surrounding form when a row is removed", async () => {
const user = userEvent.setup();
let submitted = false;
render(
<form
onSubmit={() => {
submitted = true;
}}
>
<Harness initial={rowsWith("cell-1", 10)} />
</form>,
);
await user.click(screen.getByRole("button", { name: "Remove tag limit" }));
expect(submitted).toBe(false);
expect(screen.queryAllByRole("textbox", { name: "Tag" })).toHaveLength(0);
});
});
describe("tagRowsToLimits", () => {
it("should map named rows with numeric limits into the rpm map", () => {
expect(tagRowsToLimits([{ id: "a", tag: "cell-1", rpm_limit: 60 }])).toEqual({ tag_rpm_limit: { "cell-1": 60 } });
});
it("should drop rows with a blank tag or a null limit", () => {
const rows: TagRateLimitEntry[] = [
{ id: "a", tag: " ", rpm_limit: 60 },
{ id: "b", tag: "cell-2", rpm_limit: null },
];
expect(tagRowsToLimits(rows)).toEqual({ tag_rpm_limit: {} });
});
});
describe("tagLimitsToRows", () => {
it("should rebuild rows from a stored rpm map", () => {
const rows = tagLimitsToRows({ "cell-1": 60 });
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({ tag: "cell-1", rpm_limit: 60 });
});
it("should ignore non-numeric entries", () => {
expect(tagLimitsToRows({ "cell-1": "sixty" })).toEqual([]);
});
});

View file

@ -1,5 +1,6 @@
import { Button, Input, InputNumber } from "antd";
import React from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
export interface TagRateLimitEntry {
// Stable identity for React list keys so deleting a middle row doesn't shift
@ -72,25 +73,29 @@ export function TagRateLimitEditor({ value, onChange }: TagRateLimitEditorProps)
{value.map((row, idx) => (
<div key={row.id} style={{ display: "flex", gap: 8, alignItems: "center", marginBottom: 12 }}>
<Input
aria-label="Tag"
value={row.tag}
onChange={(e) => updateRow(idx, "tag", e.target.value)}
placeholder="Tag (e.g. cell-1)"
style={{ width: 180 }}
/>
<InputNumber
<Input
aria-label="RPM limit"
type="number"
min={0}
value={row.rpm_limit ?? undefined}
onChange={(v) => updateRow(idx, "rpm_limit", v ?? null)}
value={row.rpm_limit ?? ""}
onChange={(e) => updateRow(idx, "rpm_limit", e.target.value === "" ? null : Number(e.target.value))}
placeholder="RPM"
style={{ width: 120 }}
/>
<Button type="text" danger size="small" onClick={() => removeRow(idx)} style={{ padding: "0 4px" }}>
<Button variant="destructive" size="sm" aria-label="Remove tag limit" onClick={() => removeRow(idx)}>
</Button>
</div>
))}
<Button
size="small"
variant="outline"
size="sm"
onClick={(e) => {
e.preventDefault();
addRow();

View file

@ -1,5 +1,5 @@
import React from "react";
import { Input } from "antd";
import { Input } from "@/components/ui/input";
interface routingStrategyArgs {
ttl?: number;

View file

@ -1,5 +1,5 @@
import React from "react";
import { Input } from "antd";
import { Input } from "@/components/ui/input";
interface ReliabilityRetriesSectionProps {
routerSettings: { [key: string]: any };

View file

@ -22,6 +22,11 @@ describe("TagFilteringToggle", () => {
expect(screen.getByText("Enable Tag Filtering")).toBeInTheDocument();
});
it("should name the switch with the metadata label so it is reachable by accessible name", () => {
render(<TagFilteringToggle enabled={false} routerFieldsMetadata={baseMetadata} onToggle={vi.fn()} />);
expect(screen.getByRole("switch", { name: "Tag Filtering" })).toBeInTheDocument();
});
it("should display the label from metadata when provided", () => {
render(<TagFilteringToggle enabled={false} routerFieldsMetadata={baseMetadata} onToggle={vi.fn()} />);
expect(screen.getByText("Tag Filtering")).toBeInTheDocument();

View file

@ -1,5 +1,5 @@
import React from "react";
import { Switch } from "antd";
import React, { useId } from "react";
import { Switch } from "@/components/ui/switch";
interface TagFilteringToggleProps {
enabled: boolean;
@ -8,11 +8,13 @@ interface TagFilteringToggleProps {
}
const TagFilteringToggle: React.FC<TagFilteringToggleProps> = ({ enabled, routerFieldsMetadata, onToggle }) => {
const toggleId = useId();
return (
<div className="space-y-3 max-w-3xl">
<div className="flex items-start justify-between">
<div className="flex-1">
<label className="text-xs font-medium text-gray-700 uppercase tracking-wide">
<label htmlFor={toggleId} className="text-xs font-medium text-gray-700 uppercase tracking-wide">
{routerFieldsMetadata["enable_tag_filtering"]?.ui_field_name || "Enable Tag Filtering"}
</label>
<p className="text-xs text-gray-500 mt-0.5">
@ -32,7 +34,7 @@ const TagFilteringToggle: React.FC<TagFilteringToggleProps> = ({ enabled, router
)}
</p>
</div>
<Switch checked={enabled} onChange={onToggle} className="ml-4" />
<Switch id={toggleId} checked={enabled} onCheckedChange={onToggle} className="ml-4" />
</div>
</div>
);

View file

@ -134,6 +134,33 @@ describe("RouterSettings", () => {
);
});
// handleSaveChanges reads each setting's value straight off the DOM via
// document.querySelector('input[name="..."]'), so the payload only stays correct
// while the rendered input keeps its name attribute and its live value.
it("should send the edited input value, not the loaded one, on Save Changes", async () => {
const user = userEvent.setup();
renderWithProviders(<RouterSettings {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("strategy-select")).toBeInTheDocument();
});
const numRetries = await screen.findByRole("textbox", { name: /num_retries/i });
await user.clear(numRetries);
await user.type(numRetries, "42");
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() =>
expect(setCallbacksCall).toHaveBeenCalledWith(
"test-token",
expect.objectContaining({
router_settings: expect.objectContaining({ num_retries: 42 }),
}),
),
);
});
it("should show a success notification after saving", async () => {
const user = userEvent.setup();
renderWithProviders(<RouterSettings {...defaultProps} />);

View file

@ -1,5 +1,5 @@
import { Button } from "antd";
import React, { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import NotificationsManager from "../molecules/notifications_manager";
import { getCallbacksCall, getRouterSettingsCall, setCallbacksCall } from "../networking";
import RouterSettingsForm, { RouterSettingsFormValue } from "./RouterSettingsForm";
@ -190,10 +190,10 @@ const RouterSettings: React.FC<RouterSettingsProps> = ({ accessToken, userRole,
{/* Actions - Sticky at bottom */}
<div className="border-t border-gray-200 pt-6 flex justify-end gap-3">
<Button onClick={() => window.location.reload()}>Reset</Button>
<Button type="primary" onClick={handleSaveChanges}>
Save Changes
<Button variant="outline" onClick={() => window.location.reload()}>
Reset
</Button>
<Button onClick={handleSaveChanges}>Save Changes</Button>
</div>
</div>
);

View file

@ -1,6 +1,6 @@
import React, { useState } from "react";
import { CopyToClipboard } from "react-copy-to-clipboard";
import { Button } from "antd";
import { Button } from "@/components/ui/button";
import MessageManager from "@/components/molecules/message_manager";
interface CreatedKeyDisplayProps {
@ -41,9 +41,7 @@ const CreatedKeyDisplay: React.FC<CreatedKeyDisplayProps> = ({ apiKey }) => {
</div>
<CopyToClipboard text={apiKey} onCopy={handleCopy}>
<Button type="primary" style={{ marginTop: 12 }}>
{copied ? "Copied!" : "Copy Virtual Key"}
</Button>
<Button className="mt-3">{copied ? "Copied!" : "Copy Virtual Key"}</Button>
</CopyToClipboard>
</div>
);

View file

@ -1,5 +1,5 @@
import React from "react";
import { NumberInput } from "@tremor/react";
import { Input } from "@/components/ui/input";
interface NumericalInputProps {
step?: number;
@ -7,7 +7,7 @@ interface NumericalInputProps {
placeholder?: string;
min?: number;
max?: number;
onChange?: any; // Using any to avoid type conflicts with Tremor's NumberInput
onChange?: any; // Using any to avoid type conflicts with callers that pass antd Form handlers
[key: string]: any;
}
@ -20,7 +20,7 @@ interface NumericalInputProps {
* @param {number} [props.min] - Minimum value
* @param {number} [props.max] - Maximum value
* @param {Function} [props.onChange] - On change handler
* @param {any} props.rest - Additional props passed to NumberInput
* @param {any} props.rest - Additional props passed to Input
*/
const NumericalInput: React.FC<NumericalInputProps> = ({
step = 0.01,
@ -32,7 +32,8 @@ const NumericalInput: React.FC<NumericalInputProps> = ({
...rest
}) => {
return (
<NumberInput
<Input
type="number"
onWheel={(event) => event.currentTarget.blur()}
step={step}
style={style}